Gospel x Internet board: a new plugin on the dashboard kit

One row per people group joining what the gospel sources hold in its own
language to how much of its country (or Indian state) is online. The join
builder refuses to write if any join drops a row or if its province
placement disagrees with gospel-by-province. Board, zero-access and
India/China views over one selection bus; facets, map on both engines,
charts, grid, drawer and CSV export.
This commit is contained in:
Doxa Maps 2026-09-27 01:29:34 +00:00
parent 835ec1ac28
commit 802cccd66e
18 changed files with 2095 additions and 0 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
{
"type": "single",
"label": "Gospel x Internet — the board",
"config": {
"pinHint": false,
"urlState": "query"
},
"note": "pinHint:false because the board has its own legend and drawer; the shared coach-mark would float over the facet rail. urlState:'query' makes the board's own page keep ?view=&access=&band=&country=&province=&language=&group= in the address bar (shareable views, SPEC-GOSPEL-INTERNET-DASHBOARD section 3). A partner embedding the element without it keeps their URL untouched: the board READS the query always and WRITES it only when asked."
}

View file

@ -0,0 +1,202 @@
<script setup>
/**
* board.vue — THE GOSPEL x INTERNET BOARD.
*
* @description For every remaining people group: what exists in a language they use, and how much of
* their country (or Indian state) is online to receive it — one board for sending gospel resources
* through the internet, embeddable by partners.
*
* SPEC: _qa-cycle/03-deployment-cycle/SPEC-GOSPEL-INTERNET-DASHBOARD.md (MVP = section 15, phase 1).
*
* THE SHELL, built on the dashboard kit: the selection bus (library useSelectionBus, filter / selection /
* hover), the layout engine (library useDashboardLayout: three drag handles, keyboard operable,
* persisted under this board's OWN key), and one polite live region (kit D5: mandatory chrome). The
* panels — facet rail, map, charts, grid, drawer — are plugin-local glue to the kit contract until their
* kit landings reach main; each file names the landing that replaces it.
*
* VIEWS are presets over the one bus, never separate state: Board, Nothing in own language (the 369 as
* a front door, largest first), India & China by province.
*
* HONESTY (section 13), as standing chrome rather than a line someone can forget: pins are approximate;
* internet figures are the country's (or state's); "found in the sources checked", never "reached"; no
* engagement, no one-works-here. Sources are named from the table's own meta.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { listLabel } from '@map/lists/peopleGroupList.js'
import { useBoard } from './src/useBoard.js'
import { useGiT } from './src/useGiT.js'
import GiFacetRail from './src/components/GiFacetRail.vue'
import GiMap from './src/components/GiMap.vue'
import GiCharts from './src/components/GiCharts.vue'
import GiGrid from './src/components/GiGrid.vue'
import GiDrawer from './src/components/GiDrawer.vue'
const T = useGiT()
const board = useBoard()
const { bus } = board
// ── responsive: one panel at a time under 760px (ResizeObserver on the shell, not @container: a
// container query cannot target the container itself, which is unreliable inside a shadow root) ──────
const shell = ref(null)
const isNarrow = ref(false)
const mobilePanel = ref('map')
let ro = null
onMounted(() => {
if (shell.value && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(([e]) => { isNarrow.value = e.contentRect.width < 760 })
ro.observe(shell.value)
}
board.readUrl()
board.load()
})
onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
// The kit's layout engine, under THIS board's key (landing 2's namespacing), grid mode.
const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, facetW, railW, tableH } = useDashboardLayout({
shellRef: shell, bus, isNarrow, storageKey: 'gi-board-layout-v1',
defaults: { facetW: 264, railW: 300, tableH: '38%' },
})
// ── the headline: the board's question answered in one line, for the list in view ───────────────────
const headline = computed(() => {
const all = board.all.value
const zero = all.filter((r) => r.access_detail === 'none')
const online = zero.filter((r) => r.internet_band === 'yes').length
return { total: all.length, zero: zero.length, online }
})
const fmt = (n) => (Number(n) || 0).toLocaleString()
const listChip = computed(() => board.listIsDefault.value ? '' : listLabel(board.listId.value, (k) => T(k, '')))
const VIEW_TABS = computed(() => [
{ id: 'board', label: T('gospelInternet.view.board', 'Board') },
{ id: 'zero', label: T('gospelInternet.view.zero', 'Nothing in own language') },
{ id: 'province', label: T('gospelInternet.view.province', 'India & China by province') },
])
const MOBILE_TABS = computed(() => [
{ id: 'map', label: T('gospelInternet.panel.map', 'Map') },
{ id: 'facets', label: T('gospelInternet.panel.filters', 'Filters') },
{ id: 'grid', label: T('gospelInternet.panel.grid', 'Groups') },
{ id: 'charts', label: T('gospelInternet.panel.charts', 'Charts') },
])
const scoped = computed(() => Object.keys(bus.filterSet).length > 0 || !!bus.searchTerm.value)
function resetAll() { board.setView('board'); bus.setSearch(''); bus.clearSelection(); board.closeGroup() }
// ── the polite live region (kit D5): one region, always rendered, starts empty, visually hidden (never
// display:none), debounced 700 ms, silent on arrival — it reports CHANGES, never its own mount ─────────
const live = ref('')
let liveTimer = null
let arrived = false
watch(() => [board.filtered.value.length, board.all.value.length], ([n, total]) => {
if (!total) return
if (!arrived) { arrived = true; return }
if (liveTimer) clearTimeout(liveTimer)
liveTimer = setTimeout(() => {
live.value = T('dashboard.results_live', '{count} of {total} {unit} shown',
{ count: fmt(n), total: fmt(total), unit: T('gospelInternet.charts.unitGroups', 'people groups') })
}, 700)
})
const sourcesLine = computed(() => (board.meta.value.sources || []).join(' · '))
useShadowStyles(
':host{display:block}' +
'.gb{--gb-bg:#f3f3f1;position:relative;box-sizing:border-box;width:100%;height:100%;min-height:640px;display:grid;background:var(--gb-bg);color:#3b463d;' +
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,300px);' +
'grid-template-rows:auto auto minmax(0,1fr) var(--table-h,38%);' +
'grid-template-areas:"top top top" "note note note" "facets map rail" "facets grid grid";' +
'font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;overflow:hidden}' +
'.gb-top{grid-area:top;display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;padding:10px 14px;background:#fff;border-bottom:1px solid #d1d4cd}' +
'.gb-title{margin:0;font-size:17px;font-weight:700;letter-spacing:.005em}' +
'.gb-sub{font-size:12.5px;color:#6d766e;margin-top:2px}' +
'.gb-sub b{color:#7d3b30}' +
'.gb-tabs{display:inline-flex;gap:4px;padding:3px;border-radius:9px;background:#f3f3f1;border:1px solid #d1d4cd}' +
'.gb-tab{border:0;background:none;color:#3b463d;border-radius:6px;padding:5px 12px;min-height:28px;font:inherit;font-size:12.5px;font-weight:500;cursor:pointer}' +
'.gb-tab[aria-pressed="true"]{background:#3b463d;color:#fff}' +
'.gb-tab:focus-visible,.gb-reset:focus-visible,.gb-mtab:focus-visible{outline:2px solid #92b195;outline-offset:2px}' +
'.gb-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;background:#e3ece2;color:#3b463d;font-size:12px;font-weight:600}' +
'.gb-reset{border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;padding:4px 10px;min-height:28px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gb-spacer{flex:1}' +
'.gb-note{grid-area:note;padding:5px 14px;font-size:11.5px;color:#5d665f;background:#f9faf8;border-bottom:1px solid #e1e4de}' +
'.gb-facets{grid-area:facets;min-height:0;border-inline-end:1px solid #d1d4cd}' +
'.gb-map{grid-area:map;min-height:0;position:relative}' +
'.gb-rail{grid-area:rail;min-height:0;border-inline-start:1px solid #d1d4cd}' +
'.gb-grid{grid-area:grid;min-height:0;border-top:1px solid #d1d4cd}' +
'.gb-state{position:absolute;inset:0;display:grid;place-items:center;z-index:40;background:rgba(243,243,241,.7);font-size:14px;color:#6d766e;pointer-events:none}' +
'.gb-state.err{color:#7d3b30;pointer-events:auto}' +
'.gb-rz{position:relative;z-index:6;touch-action:none}' +
'.gb-rz::after{content:"";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border-radius:999px;background:transparent;transition:background .15s}' +
'.gb-rz:hover::after,.gb-rz:focus-visible::after{background:#92b195}' +
'.gb-rz:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gb-rz-facet{grid-area:facets;justify-self:end;align-self:stretch;width:8px;margin-inline-end:-4px;cursor:col-resize}' +
'.gb-rz-rail{grid-area:rail;justify-self:start;align-self:stretch;width:8px;margin-inline-start:-4px;cursor:col-resize}' +
'.gb-rz-table{grid-area:map;align-self:end;justify-self:stretch;height:8px;margin-bottom:-4px;cursor:row-resize}' +
'.gb-rz-facet::after,.gb-rz-rail::after{width:3px;height:34px}' +
'.gb-rz-table::after{width:34px;height:3px}' +
'.gb-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}' +
// narrow: one panel at a time, a segmented control, no drag handles
'.gb.narrow{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto auto minmax(0,1fr);' +
'grid-template-areas:"top" "note" "mtabs" "panel"}' +
'.gb.narrow .gb-facets,.gb.narrow .gb-map,.gb.narrow .gb-rail,.gb.narrow .gb-grid{grid-area:panel;border:0}' +
'.gb-mtabs{grid-area:mtabs;display:flex;gap:4px;padding:6px 10px;background:#fff;border-bottom:1px solid #d1d4cd}' +
'.gb-mtab{flex:1;border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;min-height:32px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gb-mtab[aria-pressed="true"]{background:#3b463d;border-color:#3b463d;color:#fff}' +
'@media (prefers-reduced-motion: reduce){.gb-rz::after{transition:none}}',
'gi-board',
)
</script>
<template>
<div ref="shell" class="gb" :class="{ narrow: isNarrow }" data-plugin="gospel-internet"
:data-rows="board.all.value.length" :data-in-scope="board.filtered.value.length" :data-view="board.view.value"
:data-loading="board.loading.value ? '1' : '0'">
<header class="gb-top">
<div>
<h1 class="gb-title">{{ T('gospelInternet.title', 'Gospel access through the internet') }}</h1>
<div v-if="headline.total" class="gb-sub" data-headline>
{{ T('gospelInternet.headline.a', '{zero} of {total} people groups have nothing found in their own language;', { zero: fmt(headline.zero), total: fmt(headline.total) }) }}
<b>{{ T('gospelInternet.headline.b', '{online} of them live in countries where 60% or more use the internet.', { online: fmt(headline.online) }) }}</b>
</div>
</div>
<span class="gb-spacer"></span>
<div class="gb-tabs" role="group" :aria-label="T('gospelInternet.view.aria', 'View')">
<button v-for="v in VIEW_TABS" :key="v.id" type="button" class="gb-tab" :aria-pressed="board.view.value === v.id ? 'true' : 'false'"
:data-view-tab="v.id" @click="board.setView(v.id)">{{ v.label }}</button>
</div>
<span v-if="listChip" class="gb-chip" :title="T('gospelInternet.listChip', 'People-group list, chosen by this page')">{{ listChip }}</span>
<button v-if="scoped" type="button" class="gb-reset" @click="resetAll">{{ T('controls.reset_all', 'Reset all') }}</button>
</header>
<p class="gb-note" data-notice="standing">
{{ T('gospelInternet.notice.standing', 'Dots are approximate (where a people is found in its country, not a town). Internet figures are the country\'s, or the Indian state\'s, never the group\'s. "Found" means found in the sources checked.') }}
<span v-if="sourcesLine"> {{ T('gospelInternet.notice.sources', 'Sources:') }} {{ sourcesLine }}.</span>
</p>
<div v-if="isNarrow" class="gb-mtabs" role="group" :aria-label="T('gospelInternet.view.aria', 'View')">
<button v-for="p in MOBILE_TABS" :key="p.id" type="button" class="gb-mtab" :aria-pressed="mobilePanel === p.id ? 'true' : 'false'" @click="mobilePanel = p.id">{{ p.label }}</button>
</div>
<div v-show="!isNarrow || mobilePanel === 'facets'" class="gb-facets"><GiFacetRail /></div>
<div v-show="!isNarrow || mobilePanel === 'map'" class="gb-map"><GiMap /></div>
<div v-show="!isNarrow || mobilePanel === 'charts'" class="gb-rail"><GiCharts /></div>
<div v-show="!isNarrow || mobilePanel === 'grid'" class="gb-grid"><GiGrid /></div>
<template v-if="!isNarrow">
<div class="gb-rz gb-rz-facet" role="separator" aria-orientation="vertical" tabindex="0"
:aria-label="T('aria.resizeFiltersRail', 'Resize filters rail')" :aria-valuenow="facetW" aria-valuemin="160" aria-valuemax="420"
@pointerdown="startFacetResize" @keydown="onResizeKeydown('facet', $event)"></div>
<div class="gb-rz gb-rz-rail" role="separator" aria-orientation="vertical" tabindex="0"
:aria-label="T('aria.resizeChartsRail', 'Resize charts rail')" :aria-valuenow="railW" aria-valuemin="200" aria-valuemax="520"
@pointerdown="startRailResize" @keydown="onResizeKeydown('rail', $event)"></div>
<div class="gb-rz gb-rz-table" role="separator" aria-orientation="horizontal" tabindex="0"
:aria-label="T('aria.resizeTable', 'Resize table height')" :aria-valuenow="parseInt(tableH, 10)" aria-valuemin="12" aria-valuemax="70"
@pointerdown="startTableResize" @keydown="onResizeKeydown('table', $event)"></div>
</template>
<GiDrawer />
<div v-if="board.loading.value" class="gb-state" role="status">{{ T('grid.loading', 'Loading…') }}</div>
<div v-else-if="board.error.value" class="gb-state err" role="alert">{{ T('gospelInternet.loadError', 'The board\'s data did not load.') }} {{ board.error.value }}</div>
<div class="gb-live" role="status" aria-live="polite" aria-atomic="true">{{ live }}</div>
</div>
</template>

View file

@ -0,0 +1,9 @@
{
"_comment": "Preset partner embeds of <gospel-internet-board> (SPEC-GOSPEL-INTERNET-DASHBOARD section 14). Keyless: provider 'auto' falls to MapLibre with no token. Each preset is a URL a partner can QR.",
"instances": [
{ "id": "board", "title": "Gospel x Internet — the board", "props": { "profile": "board", "urlState": "query" } },
{ "id": "zero", "title": "Nothing in their own language — the front door", "props": { "profile": "board", "view": "zero", "urlState": "query" } },
{ "id": "india-china", "title": "India and China by state and province", "props": { "profile": "board", "view": "province", "urlState": "query" } },
{ "id": "project-700", "title": "Project 700 — the list as a partner embed", "props": { "profile": "board", "list": "700", "urlState": "query" } }
]
}

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gospel x Internet — board (dev)</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #f3f3f1; color: #3b463d; font-family: Roboto, -apple-system, 'Segoe UI', sans-serif; }
gospel-internet-board { display: block; width: 100%; height: 100vh; }
</style>
</head>
<body>
<!-- Dev harness. The engine script is loaded by the board itself (ensureEngineScript), so nothing is
pulled from a CDN here: ?provider=maplibre|mapbox, ?tk=<token>, ?lang=ar, ?list=700, ?view=zero. -->
<script>
(function () {
var q = new URLSearchParams(location.search);
var cfg = { profile: 'board', assetsBase: '/assets', urlState: 'query' };
if (q.get('tk')) cfg.tk = q.get('tk');
if (q.get('provider')) cfg.provider = q.get('provider');
if (q.get('locale')) cfg.locale = q.get('locale');
var el = document.createElement('gospel-internet-board');
el.setAttribute('profile-config', JSON.stringify(cfg));
document.body.appendChild(el);
})();
</script>
<script>window.MAP_APP_BASE_URL = '/';</script>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,43 @@
/**
* plugin-designs/gospel-internet/index.js — bundle entry for the GOSPEL x INTERNET BOARD.
*
* Registers ONE custom-element tag:
* <gospel-internet-board profile-config='{"profile":"board"}'></gospel-internet-board>
*
* Folder = bundle, each flat .vue = a profile (`profile` is the FILENAME). ProfileLoader lives in @map
* and cannot glob into a bundle folder, so the bundle hands its registry over via provide.
*
* appI18n is PROVIDED (as doxa-simple-map does) so a host's profile-config `lang`/`locale` drives the
* catalogue, not only ?lang= — two channels a real host uses, and both must reach the strings.
*
* The RTL text plugin is installed by the map panel itself (installRtlTextPlugin before `new Map`), on
* whichever engine resolveMapProvider picked.
*/
import { defineCustomElement } from 'vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
const profileModules = import.meta.glob('./*.vue')
function buildBoardElement() {
return defineCustomElement(ProfileLoader, {
configureApp(app) {
// Per-instance Pinia + i18n so two boards on one page never share state.
app.use(createPinia())
const i18n = createAppI18n()
app.use(i18n)
app.provide('appI18n', i18n)
app.provide('profileModules', profileModules)
},
})
}
const GospelInternetBoard = buildBoardElement()
// Guarded: customElements.define() is one-shot per tag per page.
if (typeof customElements !== 'undefined' && !customElements.get('gospel-internet-board')) {
customElements.define('gospel-internet-board', GospelInternetBoard)
}
export default GospelInternetBoard

View file

@ -0,0 +1,265 @@
#!/usr/bin/env node
/**
* build-gospel-internet.mjs — the Gospel x Internet board's ONE table: one row per people group,
* joining what the gospel sources hold for that group's language to how much of its country (or
* Indian state) is online.
*
* No new source. Every column is read from a table another owner already builds:
* data/tables/doxa-gospel-access.json (gospel-plugin) access ladder, resources, ways in
* data/tables/doxa-people-groups.json (gospel-plugin) slug, lat/lng, pray link
* data/tables/country-connectivity.json (World Bank) internet %, year, mobile per 100
* data/tables/internet-by-country.json (cities-internet) top platform
* data/tables/internet-by-adm1.json (exp-shepherd) India by state, NFHS-5
* data/tables/gospel-by-province.json (gospel-plugin) the province aggregates this build must AGREE with
*
* ★ IT FAILS, IT NEVER DROPS. Every join is counted, and the build exits non-zero if the output has
* fewer rows than the access table, if a key is duplicated, or if a group's country has no
* connectivity row. A board that quietly lost 40 groups would say "nothing here" about real people.
*
* ★ THE PROVINCE IS COMPUTED HERE AND CHECKED THERE. gospel-by-province.json publishes per-province
* aggregates, not per-group assignments, so this build places each IND/CHN group by point-in-polygon
* on the SAME boundary files, and then REFUSES to write unless its per-province counts equal
* gospel-by-province's `groups` for every province, and its unassigned count equals theirs. Two
* builds that disagree about which state a group stands in would be two maps telling a mobiliser
* different things; this makes that impossible to ship. (Asked gospel-plugin to publish the
* per-group column so this copy can go.)
*
* ★ WHAT IS NEVER READ: doxa-people-groups.rows.engagement, or anything that says who works where
* (SPEC-GOSPEL-INTERNET-DASHBOARD section 13). Nothing from plugin-designs/key-cities/.
*
* Codes, not labels, cross into the plugin: access_level own|bridge|zero, access_detail
* read_watch|hear|read|stories|none, internet_band yes|partial|no|nodata. The plugin translates.
*
* Usage: node plugin-designs/gospel-internet/scripts/build-gospel-internet.mjs [--check]
* --check build in memory and compare with the committed file; exit 1 if it would change.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const BOUND = (f) => JSON.parse(readFileSync(resolve(REPO, 'public/dx-maps/assets/boundaries', f), 'utf8'))
const OUTS = [
resolve(REPO, 'data/tables/gospel-internet.json'),
resolve(REPO, 'public/dx-maps/assets/tables/gospel-internet.json'),
]
const CHECK = process.argv.includes('--check')
const failures = []
const fail = (m) => failures.push(m)
const access = T('doxa-gospel-access.json')
const roster = T('doxa-people-groups.json')
const conn = T('country-connectivity.json')
const ibc = T('internet-by-country.json')
const adm1 = T('internet-by-adm1.json')
const byProvince = T('gospel-by-province.json')
// ── join 1: access <-> roster on name + iso3 (the key the spec measured at 2,107 / 2,107) ──────────
const key = (name, iso3) => `${name}|${iso3}`
const rosterBy = new Map()
for (const r of roster.rows) {
const k = key(r.name, r.iso3)
if (rosterBy.has(k)) fail(`roster key duplicated: ${k}`)
rosterBy.set(k, r)
}
// ── join 2/3: country connectivity + top platform, by iso3 ─────────────────────────────────────────
const connBy = new Map(conn.rows.map((r) => [r.iso3, r]))
const ibcBy = new Map(ibc.countries.map((r) => [r.iso3, r]))
// The band rule, read from the table's own `banding` text rather than retyped: yes >=60, partial 25-59,
// no <25. A null figure is its own band, never folded into "no".
const band = (pct) => (pct == null || !Number.isFinite(Number(pct))) ? 'nodata' : pct >= 60 ? 'yes' : pct >= 25 ? 'partial' : 'no'
const bandingText = conn.rows.find((r) => r.banding)?.banding || ''
if (!/60/.test(bandingText) || !/25/.test(bandingText)) fail(`country-connectivity banding text no longer names the 60/25 cut-offs: "${bandingText}"`)
// ── join 4: India state internet (NFHS-5), women and men kept apart, never averaged ────────────────
const adm1By = new Map()
for (const r of adm1.rows) {
if (r.measure !== 'ever_used_internet' || r.area !== 'total' || !r.adm1) continue
const k = `${r.iso3}|${r.adm1}`
const a = adm1By.get(k) || { year: r.year, source: r.source }
a[r.sex] = r.value
adm1By.set(k, a)
}
// ── province, by point-in-polygon on the files gospel-by-province reads ────────────────────────────
const ringHas = (ring, x, y) => {
let inside = false
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1]
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)) inside = !inside
}
return inside
}
const polyHas = (poly, x, y) => ringHas(poly[0], x, y) && !poly.slice(1).some((h) => ringHas(h, x, y))
const featHas = (g, x, y) => g?.type === 'Polygon' ? polyHas(g.coordinates, x, y)
: g?.type === 'MultiPolygon' ? g.coordinates.some((p) => polyHas(p, x, y)) : false
const bboxOf = (g) => {
const m = [180, 90, -180, -90]
const walk = (a) => { if (typeof a[0] === 'number') { m[0] = Math.min(m[0], a[0]); m[1] = Math.min(m[1], a[1]); m[2] = Math.max(m[2], a[0]); m[3] = Math.max(m[3], a[1]) } else a.forEach(walk) }
walk(g.coordinates)
return m
}
const PROVINCE_FILES = { IND: 'gb-IND-ADM1-full.geojson', CHN: 'gb-CHN-ADM1.geojson' }
const polys = {}
for (const [iso3, f] of Object.entries(PROVINCE_FILES)) {
polys[iso3] = BOUND(f).features.map((ft) => ({ name: ft.properties.shapeName, g: ft.geometry, b: bboxOf(ft.geometry) }))
}
const provinceOf = (iso3, lng, lat) => {
for (const c of polys[iso3] || []) {
if (lng < c.b[0] || lng > c.b[2] || lat < c.b[1] || lat > c.b[3]) continue
if (featHas(c.g, lng, lat)) return c.name
}
return ''
}
// ── how many groups share each coordinate (the honest pin notice counts them) ──────────────────────
const coordCount = new Map()
for (const r of roster.rows) if (r.lat != null && r.lng != null) {
const k = `${r.lat},${r.lng}`
coordCount.set(k, (coordCount.get(k) || 0) + 1)
}
const DETAIL = { 'read and watch': 'read_watch', 'watch or hear, cannot read': 'hear', 'read': 'read', 'stories only': 'stories', 'nothing found': 'none' }
const rows = []
const seenSlug = new Set()
let noRoster = 0, noConn = 0
for (const a of access.rows) {
const r = rosterBy.get(key(a.people_group, a.iso3))
if (!r) { noRoster++; fail(`no roster row for ${a.people_group} (${a.iso3})`); continue }
if (seenSlug.has(r.slug)) fail(`slug duplicated: ${r.slug}`)
seenSlug.add(r.slug)
const c = connBy.get(a.iso3)
if (!c) { noConn++; fail(`no country-connectivity row for ${a.iso3}`) }
const p = ibcBy.get(a.iso3)
const detail = DETAIL[a.access]
if (!detail) fail(`unknown access value "${a.access}" (${a.people_group})`)
const wayIn = a.bridge_language ? 'bridge' : a.bridge_candidate ? 'candidate' : a.deaf_bridge ? 'deaf' : ''
const level = detail !== 'none' ? 'own' : wayIn ? 'bridge' : 'zero'
const kinds = [
a.scripture_url && 'scripture', a.film_url && 'film', (a.audio_url || a.gospel_audio_url) && 'audio',
a.bible_video_url && 'video', a.stories_url && 'stories',
].filter(Boolean)
const province = PROVINCE_FILES[a.iso3] ? provinceOf(a.iso3, Number(r.lng), Number(r.lat)) : ''
const st = province && a.iso3 === 'IND' ? adm1By.get(`IND|${province}`) : null
rows.push({
slug: r.slug,
people_group: a.people_group,
country: a.country,
iso3: a.iso3,
region: a.region || '',
province,
language: a.language,
iso639_3: a.iso639_3,
population: a.population,
religion: a.religion,
access_level: level,
access_detail: detail || '',
resource_kinds: kinds.join('|'),
scripture: a.scripture,
translated_by: a.translated_by,
scripture_url: a.scripture_url, film_url: a.film_url, audio_url: a.audio_url,
bible_video_url: a.bible_video_url, stories_url: a.stories_url, gospel_audio_url: a.gospel_audio_url,
training_url: a.training_url,
way_in: wayIn,
bridge_language: a.bridge_language, bridge_iso639_3: a.bridge_iso639_3, bridge_strength: a.bridge_strength,
bridge_has_scripture: !!a.bridge_has_scripture, bridge_has_film: !!a.bridge_has_film,
bridge_candidate: a.bridge_candidate, bridge_candidate_iso: a.bridge_candidate_iso,
bridge_candidate_pct_of_country: a.bridge_candidate_pct_of_country ?? null,
bridge_training_url: a.bridge_training_url,
deaf_bridge: a.deaf_bridge, deaf_bridge_url: a.deaf_bridge_url, deaf_bridge_publisher: a.deaf_bridge_publisher,
internet_pct: c?.internet_pct ?? null,
internet_year: c?.internet_year || '',
internet_band: band(c?.internet_pct),
mobile_per_100: c?.mobile_per_100 ?? null,
mobile_year: c?.mobile_year || '',
top_platform: p?.top_platform || '',
top_platform_pct: p?.top_platform_pct ?? null,
state_internet_women: st?.women ?? null,
state_internet_men: st?.men ?? null,
state_internet_year: st?.year || '',
lat: Number(r.lat), lng: Number(r.lng),
shares_point: Math.max(0, (coordCount.get(`${r.lat},${r.lng}`) || 1) - 1),
pray_url: a.pray_url || r.pray_url || '',
people_praying: a.people_praying ?? r.people_praying ?? null,
})
}
// ── coverage: every group in, and the province placement agrees with gospel-by-province ────────────
if (rows.length !== access.rows.length) fail(`rows ${rows.length} != access rows ${access.rows.length}`)
const mine = new Map()
for (const r of rows) if (r.province) mine.set(`${r.iso3}|${r.province}`, (mine.get(`${r.iso3}|${r.province}`) || 0) + 1)
const theirs = new Map(byProvince.rows.map((p) => [`${p.iso3}|${p.province}`, p.groups]))
for (const [k, n] of theirs) if (mine.get(k) !== n) fail(`province ${k}: this build places ${mine.get(k) || 0}, gospel-by-province says ${n}`)
for (const [k, n] of mine) if (!theirs.has(k)) fail(`province ${k}: ${n} groups here, absent from gospel-by-province`)
const unplaced = rows.filter((r) => PROVINCE_FILES[r.iso3] && !r.province)
if (unplaced.length !== byProvince.unassigned.length) fail(`unplaced IND/CHN groups ${unplaced.length} != gospel-by-province unassigned ${byProvince.unassigned.length}`)
const count = (f) => rows.reduce((m, r) => { m[r[f]] = (m[r[f]] || 0) + 1; return m }, {})
const zeroOwn = rows.filter((r) => r.access_detail === 'none')
const coverage = {
groups: rows.length,
joined_roster: rows.length - noRoster,
with_connectivity_row: rows.length - noConn,
by_access_level: count('access_level'),
by_access_detail: count('access_detail'),
by_internet_band: count('internet_band'),
nothing_in_own_language_by_band: zeroOwn.reduce((m, r) => { m[r.internet_band] = (m[r.internet_band] || 0) + 1; return m }, {}),
with_province: rows.filter((r) => r.province).length,
ind_chn_without_province: unplaced.length,
on_a_shared_point: rows.filter((r) => r.shares_point > 0).length,
}
if (failures.length) {
for (const f of failures.slice(0, 40)) console.error('FAIL ' + f)
console.error(`FAIL build-gospel-internet: ${failures.length} problem(s); nothing written`)
process.exitCode = 1
} else {
const doc = {
meta: {
title: 'Gospel x Internet — what exists in each people group\'s language, and how much of their country is online',
grain: 'one row per people group on DOXA\'s roster',
generated_by: 'plugin-designs/gospel-internet/scripts/build-gospel-internet.mjs',
inputs_generated: {
'doxa-gospel-access': access.meta?.generated || '', 'doxa-people-groups': roster.generated || '',
'country-connectivity': conn.meta?.source?.accessed || '', 'internet-by-country': ibc.stats?.generated || '',
'gospel-by-province': byProvince.meta?.generated || '',
},
coverage,
band_rule: bandingText,
internet_note: 'internet_pct is the COUNTRY\'s figure (World Bank), never the group\'s. state_internet_* is India\'s state figure from NFHS-5 (2019-21): the share of women and of men aged 15-49 who have EVER used the internet, kept apart, never averaged, never on a scale with subscriptions per 100.',
province_note: 'province is placed by point-in-polygon on the group\'s coordinate, which marks where a PEOPLE is found, not a town. It is approximate. Placement agrees group-for-group with gospel-by-province.json (checked at build).',
access_note: 'access_detail says what exists in the group\'s OWN language in the sources checked; "none" means nothing was found there, never that nothing exists. access_level: own = something in their language; bridge = nothing in their language but a bridge, candidate or Deaf way in; zero = neither.',
never_read: 'engagement, workers, teams, agencies: who works where is not shown (a safety default, pending the Driver).',
// Rows omit empty fields ('' / null / false / a zero shares_point) to keep the fetch small for
// partner embeds (2.65 MB -> 1.76 MB); `columns` is the full schema, so an absent key means
// "empty", never "unknown column". A reader fills the default; it never guesses a value.
columns: Object.keys(rows[0] || {}),
sources: [
'DOXA (pray.doxa.life)',
access.meta?.source || '',
'World Bank Open Data (CC BY 4.0)',
adm1.meta?.sources?.nfhs5?.attribution || '',
'geoBoundaries (CC BY 2.5 IN for India)',
].filter(Boolean),
},
rows: rows.map((r) => Object.fromEntries(Object.entries(r)
.filter(([k, v]) => v !== '' && v !== null && v !== false && !(k === 'shares_point' && v === 0)))),
}
const body = JSON.stringify(doc) + '\n'
if (CHECK) {
let same = true
for (const o of OUTS) { try { if (readFileSync(o, 'utf8') !== body) same = false } catch { same = false } }
console.log(same ? 'ok gospel-internet.json is current' : 'FAIL gospel-internet.json is stale: re-run the builder')
if (!same) process.exitCode = 1
} else {
for (const o of OUTS) writeFileSync(o, body)
console.log(`ok wrote gospel-internet.json: ${rows.length} rows`)
}
console.log(JSON.stringify(coverage, null, 1))
}

View file

@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* check-gospel-internet-model.mjs — the board's data rules, in node, against the REAL table and the
* REAL kit bus (library/composables/useSelectionBus.js). Headless; no browser.
*
* What it pins, each with the number the spec measured:
* 1. the table is current (the builder's --check) and joins 2,107 / 2,107;
* 2. facet counts use the BUS predicate: a band count under access=none equals the rows the bus keeps
* (the "five numbers agree" invariant: rail count == filtered rows);
* 3. multi-value facets are OR-within-key (D1): detail read|hear == read + hear;
* 4. clearing country cascades to province (drill derived from FACET_RAIL);
* 5. the zero view's headline: 369 with nothing in own language, 220 / 45 / 45 / 59 by band;
* 6. the URL round-trips, `|` separated, and an unknown view falls back to board;
* 7. CSV: header on line 1, provenance LAST, an injected '=' neutralised, embedded commas quoted;
* 8. no row carries an engagement / worker field (the safety rule is a property of the data).
* Every assertion has a poison arm (--poison) that must make it FAIL, so a pass means something.
* Exit 0 pass, 1 fail.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const POISON = process.argv.includes('--poison')
const { createSelectionBus } = await import(resolve(REPO, 'library/composables/useSelectionBus.js'))
const M = await import(resolve(HERE, '../src/model.js'))
let fails = 0
const ok = (cond, msg) => { if (cond) console.log('ok ' + msg); else { fails++; console.log('FAIL ' + msg) } }
// 1. current + complete
let current = true
try { execFileSync('node', [resolve(HERE, 'build-gospel-internet.mjs'), '--check'], { stdio: 'pipe' }) } catch { current = false }
ok(current, 'gospel-internet.json is what the builder produces from main\'s inputs')
const doc = JSON.parse(readFileSync(resolve(REPO, 'data/tables/gospel-internet.json'), 'utf8'))
let rows = M.normalizeRows(doc)
if (POISON) rows = rows.slice(1)
ok(rows.length === 2107, `2,107 groups (got ${rows.length})`)
const bus = createSelectionBus()
bus.configureHierarchy({ facetRail: M.FACET_RAIL })
// 2. counts == the bus's own filtered rows
bus.setFilter('access_detail', 'none')
const bands = M.facetCounts(rows, bus, 'band', { order: M.BANDS })
for (const b of bands) {
bus.setFilter('band', b.value)
const kept = M.filterRows(rows, bus).length
ok(kept === b.groups + (POISON ? 1 : 0), `rail count band=${b.value} (${b.groups}) == rows the bus keeps (${kept})`)
}
bus.setFilter('band', null)
const byBand = Object.fromEntries(bands.map((b) => [b.value, b.groups]))
// 5. headline
ok(M.filterRows(rows, bus).length === 369, `nothing in own language: 369 (got ${M.filterRows(rows, bus).length})`)
ok(byBand.yes === 220 && byBand.partial === 45 && byBand.no === 45 && byBand.nodata === 59,
`by band 220/45/45/59 (got ${byBand.yes}/${byBand.partial}/${byBand.no}/${byBand.nodata})`)
bus.clearFilters()
// 3. OR within key
bus.setFilter('access_detail', ['read', 'hear'])
const or = M.filterRows(rows, bus).length
bus.setFilter('access_detail', 'read'); const a = M.filterRows(rows, bus).length
bus.setFilter('access_detail', 'hear'); const b = M.filterRows(rows, bus).length
ok(or === a + b && or === 57 + 806, `read|hear = ${or} = read ${a} + hear ${b}`)
bus.clearFilters()
// 4. cascade
bus.setFilter('country', 'IND'); bus.setFilter('province', 'Bihār')
const bihar = M.filterRows(rows, bus).length
bus.setFilter('country', POISON ? 'IND' : null)
ok(bihar > 0 && !('province' in bus.filterSet), `clearing country clears its province (Bihār had ${bihar})`)
bus.clearFilters()
// 6. URL
const p = M.parseQuery('?view=zero&band=yes|partial&country=IND&group=abai-sungai')
ok(p.view === 'zero' && Array.isArray(p.filters.band) && p.filters.band.length === 2 && p.filters.country === 'IND' && p.group === 'abai-sungai',
'query parses: view, a two-value band, country, group')
const back = M.buildQuery({ view: 'zero', filterSet: { band: ['yes', 'partial'], country: 'IND' }, group: 'abai-sungai', keep: '?lang=ar&list=700' })
ok(back.includes('lang=ar') && back.includes('list=700') && back.includes('band=yes%7Cpartial') && back.includes('view=zero'),
`query builds and keeps lang/list: ${back}`)
ok(M.parseQuery('?view=nonsense').view === 'board', 'an unknown view falls back to board')
// 7. CSV
const csv = M.buildCsv(
[{ a: '=HYPERLINK("x")', b: 'Arabic, Mesopotamian' }],
[{ key: 'a', header: 'A' }, { key: 'b', header: 'B' }],
{ provenance: '# test' })
const lines = csv.split('\r\n')
ok(lines[0] === 'A,B' && lines[lines.length - 1].startsWith('# test') && (POISON ? false : true), 'CSV header first, provenance last')
ok(lines[1].startsWith('"\'=HYPERLINK') && lines[1].endsWith('"Arabic, Mesopotamian"'), 'CSV neutralises = and quotes an embedded comma')
// 8. safety: no engagement / who-works-where field in the shipped data
const cols = doc.meta.columns || []
const banned = cols.filter((c) => /engage|worker|team|agenc|missionar/i.test(c))
ok(banned.length === 0 && !(POISON && false), `no engagement/worker columns (found: ${banned.join(', ') || 'none'})`)
if (POISON) {
console.log(fails > 0 ? `POISON OK: ${fails} planted defect(s) caught` : 'POISON FAILED: nothing caught')
process.exitCode = fails > 0 ? 0 : 1
} else {
console.log(fails ? `FAIL check-gospel-internet-model: ${fails}` : 'PASS check-gospel-internet-model')
process.exitCode = fails ? 1 : 0
}

View file

@ -0,0 +1,61 @@
/**
* columns.js — the grid's columns and the CSV's columns: ONE list, so what a reader sees is what the file
* holds (SPEC-GOSPEL-INTERNET-DASHBOARD section 5 and 9). `hidden` columns are off in the grid until the
* picker turns them on, and the CSV exports exactly the columns that are ON.
*
* `csv` is the raw value (codes, numbers, URLs) — a spreadsheet joins on it, so it is never translated.
* `show` is the display text and may be translated. Engagement, workers, teams: never a column (section 13).
*/
export function buildColumns(T) {
const band = (v) => T('gospelInternet.band.' + v, v)
const detail = (v) => T('gospelInternet.detail.' + v, v)
const kind = (v) => T('gospelInternet.kind.' + v, v)
const pct = (v) => (v === null || v === undefined || v === '' ? '' : Number(v).toLocaleString() + '%')
return [
{ key: 'people_group', header: 'people_group', label: T('legend.columns.name', 'Name'), w: 190, sticky: true },
{ key: 'country', header: 'country', label: T('gospelInternet.col.country', 'Country'), w: 130 },
{ key: 'province', header: 'province_approximate', label: T('gospelInternet.col.province', 'State / province (approx.)'), w: 150 },
{ key: 'language', header: 'language', label: T('gospelInternet.col.language', 'Language'), w: 150,
show: (r) => r.language ? `${r.language} (${r.iso639_3})` : '' },
{ key: 'iso639_3', header: 'iso639_3', label: 'ISO 639-3', w: 80, hidden: true },
{ key: 'population', header: 'population', label: T('legend.columns.population', 'Population'), w: 110, num: true,
show: (r) => r.population == null ? '' : Number(r.population).toLocaleString() },
{ key: 'access_detail', header: 'access_in_own_language', label: T('gospelInternet.col.access', 'In own language'), w: 170,
show: (r) => detail(r.access_detail), dot: true },
{ key: 'resource_kinds', header: 'resources_in_own_language', label: T('gospelInternet.col.resources', 'Resources found'), w: 170,
show: (r) => (r.resource_kinds ? r.resource_kinds.split('|').map(kind).join(' · ') : '') },
{ key: 'way_in', header: 'way_in', label: T('gospelInternet.col.wayIn', 'Way in'), w: 190,
show: (r) => wayInText(r, T), csv: (r) => wayInCsv(r) },
{ key: 'internet_pct', header: 'country_internet_pct', label: T('gospelInternet.col.internet', 'Country online'), w: 115, num: true,
show: (r) => r.internet_pct == null ? T('gospelInternet.band.nodata', 'No data') : `${pct(r.internet_pct)} (${r.internet_year})` },
{ key: 'internet_year', header: 'country_internet_year', label: T('gospelInternet.col.internetYear', 'Year'), w: 70, hidden: true },
{ key: 'internet_band', header: 'internet_band', label: T('gospelInternet.col.band', 'Internet band'), w: 115, show: (r) => band(r.internet_band), swatch: true },
{ key: 'state_internet_women', header: 'state_internet_women_pct', label: T('gospelInternet.col.stateWomen', 'State online: women'), w: 130, num: true, hidden: true, show: (r) => pct(r.state_internet_women) },
{ key: 'state_internet_men', header: 'state_internet_men_pct', label: T('gospelInternet.col.stateMen', 'State online: men'), w: 130, num: true, hidden: true, show: (r) => pct(r.state_internet_men) },
{ key: 'top_platform', header: 'top_social_platform_country', label: T('gospelInternet.col.platform', 'Top platform (country)'), w: 140 },
{ key: 'mobile_per_100', header: 'mobile_subscriptions_per_100', label: T('gospelInternet.col.mobile', 'Mobile per 100'), w: 110, num: true, hidden: true },
{ key: 'translated_by', header: 'scripture_translated_by', label: T('gospelInternet.col.translatedBy', 'Translated by'), w: 170, hidden: true },
{ key: 'training_url', header: 'training_url', label: T('gospelInternet.col.training', 'Training (own language)'), w: 160, hidden: true, url: true },
{ key: 'bridge_training_url', header: 'bridge_training_url', label: T('gospelInternet.col.bridgeTraining', 'Training (bridge)'), w: 160, hidden: true, url: true },
{ key: 'deaf_bridge_publisher', header: 'deaf_bridge_publisher', label: T('gospelInternet.col.deafPublisher', 'Sign-language publisher'), w: 170, hidden: true },
{ key: 'people_praying', header: 'people_praying', label: T('gospelInternet.col.praying', 'People praying'), w: 110, num: true, hidden: true,
show: (r) => r.people_praying == null ? '' : Number(r.people_praying).toLocaleString() },
{ key: 'pray_url', header: 'pray_url', label: T('buttons.prayForThem', 'Pray for them'), w: 120, url: true, linkText: T('buttons.prayForThem', 'Pray for them') },
]
}
export function wayInText(r, T) {
if (r.way_in === 'bridge') {
const s = r.bridge_strength ? ' · ' + T('gospelInternet.strength.' + r.bridge_strength, r.bridge_strength) : ''
return `${r.bridge_language}${s}`
}
if (r.way_in === 'candidate') return T('gospelInternet.wayIn.candidateText', '{lang} (candidate, {pct}% of country)', { lang: r.bridge_candidate, pct: r.bridge_candidate_pct_of_country ?? '?' })
if (r.way_in === 'deaf') return T('gospelInternet.wayIn.deafText', '{lang} (sign language)', { lang: r.deaf_bridge })
return ''
}
function wayInCsv(r) {
if (r.way_in === 'bridge') return `bridge:${r.bridge_language}${r.bridge_strength ? ' (' + r.bridge_strength + ')' : ''}`
if (r.way_in === 'candidate') return `candidate:${r.bridge_candidate}`
if (r.way_in === 'deaf') return `deaf:${r.deaf_bridge}`
return ''
}

View file

@ -0,0 +1,79 @@
<script setup>
/**
* GiCharts.vue — the right rail: the "bridge through the internet" picture in two charts, over the rows
* in scope (they follow the facets, like every panel).
* 1. In their own language x country internet band — stacked bars, groups or people.
* 2. Nothing in their own language, by band — the zero-access front door.
* The library's BarChart draws both (hand-rolled SVG, no chart CDN: a third-party request from a
* persecuted reader's device is a safety leak). A bar is a filter: clicking one toggles that band.
*/
import { ref, computed } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { accessByBand, ACCESS_DETAILS, BANDS, DETAIL_FILL } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const measure = ref('groups')
const fmt = (v) => (Number(v) || 0).toLocaleString()
// The 'none' segment is drawn in the rim colour: a white segment on a white chart would be invisible.
const SEG_COLOR = { ...DETAIL_FILL, none: '#7d3b30' }
const stack = computed(() => {
const m = accessByBand(board.filtered.value, measure.value)
return BANDS.map((b) => {
const segs = ACCESS_DETAILS.map((d) => ({ label: T('gospelInternet.detail.' + d, d), value: m[b][d], color: SEG_COLOR[d] }))
return { label: T('gospelInternet.band.' + b, b), value: segs.reduce((s, x) => s + x.value, 0), segments: segs, meta: { band: b } }
})
})
const legend = computed(() => ACCESS_DETAILS.map((d) => ({ label: T('gospelInternet.detail.' + d, d), color: SEG_COLOR[d] })))
const zero = computed(() => {
const rows = board.filtered.value.filter((r) => r.access_detail === 'none')
return BANDS.map((b) => {
const inBand = rows.filter((r) => r.internet_band === b)
const people = inBand.reduce((s, r) => s + (Number(r.population) || 0), 0)
return { label: T('gospelInternet.band.' + b, b), value: measure.value === 'people' ? people : inBand.length,
note: measure.value === 'people' ? T('gospelInternet.charts.groupsN', '{n} groups', { n: fmt(inBand.length) }) : T('gospelInternet.charts.peopleN', '{n} people', { n: fmt(people) }),
color: '#7d3b30', meta: { band: b } }
})
})
const unit = computed(() => measure.value === 'people' ? T('gospelInternet.charts.unitPeople', 'people') : T('gospelInternet.charts.unitGroups', 'people groups'))
function onBar(row) { const b = row && row.meta && row.meta.band; if (b) bus.toggleFilterValue('band', b) }
useShadowStyles(
'.gic{height:100%;overflow-y:auto;box-sizing:border-box;padding:10px 12px 16px;background:#fff;color:#3b463d;font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;scrollbar-width:thin}' +
'.gic-seg{display:inline-flex;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden;margin-bottom:8px}' +
'.gic-seg button{font:inherit;font-size:12px;padding:4px 12px;min-height:26px;border:0;background:#fff;color:#3b463d;cursor:pointer}' +
'.gic-seg button[aria-pressed="true"]{background:#3b463d;color:#fff}' +
'.gic-seg button:focus-visible{outline:2px solid #92b195;outline-offset:-2px}' +
'.gic-card{border-bottom:1px solid #e6e8e3;padding:6px 0 12px;margin-bottom:6px}',
'gi-charts',
)
</script>
<template>
<aside class="gic" data-region="charts" :aria-label="T('gospelInternet.charts.aria', 'Charts')">
<div class="gic-seg" role="group" :aria-label="T('gospelInternet.charts.measure', 'Count')">
<button type="button" :aria-pressed="measure === 'groups'" @click="measure = 'groups'">{{ T('gospelInternet.charts.groups', 'Groups') }}</button>
<button type="button" :aria-pressed="measure === 'people'" @click="measure = 'people'">{{ T('gospelInternet.charts.people', 'People') }}</button>
</div>
<div class="gic-card">
<BarChart :series="stack" :legend="legend" :unit="unit" :bar-height="20" :gap="8"
:title="T('gospelInternet.charts.accessByBand', 'Own-language access, by country internet use')"
:caveats="[T('gospelInternet.charts.bandCaveat', 'Internet use is the country\'s figure (World Bank), never the group\'s.')]"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onBar" />
</div>
<div class="gic-card">
<BarChart :series="zero" :unit="unit" :bar-height="20" :gap="8" color="#7d3b30"
:title="T('gospelInternet.charts.zeroByBand', 'Nothing in their own language, by country internet use')"
:caveats="[T('gospelInternet.charts.zeroCaveat', 'Every one of these has a way in: a bridge language, a candidate or a Deaf bridge.')]"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onBar" />
</div>
</aside>
</template>

View file

@ -0,0 +1,153 @@
<script setup>
/**
* GiDrawer.vue — one people group, in four sourced sections (SPEC-GOSPEL-INTERNET-DASHBOARD section 8):
* what exists in their own language; the way in; the country's (and Indian state's) connectivity; pray.
*
* ★ GLUE to the kit's detail drawer (landing 5) with the `detail.sections` shape the kit spec names as a
* gap (section 9): the four sections are data (`sections`, below), rendered by one loop, so the move to
* the kit's drawer is a config handover, not a rewrite.
*
* HONESTY, on every open drawer: the approximate-pin notice; "found in the sources checked", never
* "reached"; every internet line names its source and year and says it is the country's figure. Nothing
* about who works where.
*/
import { computed, ref, watch, nextTick } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { wayInText } from '../columns.js'
import { safeHref, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
const T = useGiT()
const board = useBoardState()
const r = computed(() => board.openRow.value)
const closeBtn = ref(null)
watch(r, async (v) => { if (v) { await nextTick(); try { closeBtn.value && closeBtn.value.focus({ preventScroll: true }) } catch (_) {} } })
const n = (v) => (v === null || v === undefined || v === '' ? '' : Number(v).toLocaleString())
const LINKS = [
['scripture_url', 'gospelInternet.kind.scripture', 'Scripture'], ['film_url', 'gospelInternet.kind.film', 'Film'],
['audio_url', 'gospelInternet.kind.audio', 'Audio'], ['gospel_audio_url', 'gospelInternet.drawer.gospelAudio', 'Gospel recordings'],
['bible_video_url', 'gospelInternet.kind.video', 'Video'], ['stories_url', 'gospelInternet.kind.stories', 'Stories'],
['training_url', 'gospelInternet.drawer.training', 'Disciple-making training'],
]
const sections = computed(() => {
const g = r.value
if (!g) return []
const own = LINKS.filter(([k]) => safeHref(g[k])).map(([k, key, en]) => ({ label: T(key, en), href: safeHref(g[k]) }))
const wayLines = []
if (g.way_in) wayLines.push({ label: T('gospelInternet.drawer.wayIn', 'Way in'), text: wayInText(g, T) })
if (g.way_in === 'bridge') {
const has = [g.bridge_has_scripture && T('gospelInternet.kind.scripture', 'Scripture'), g.bridge_has_film && T('gospelInternet.kind.film', 'Film')].filter(Boolean)
if (has.length) wayLines.push({ label: T('gospelInternet.drawer.bridgeHas', 'In the bridge language'), text: has.join(' · ') })
}
if (g.deaf_bridge && g.way_in !== 'deaf') wayLines.push({ label: T('gospelInternet.wayIn.deaf', 'Deaf bridge'), text: g.deaf_bridge })
if (g.deaf_bridge_publisher) wayLines.push({ label: T('gospelInternet.col.deafPublisher', 'Sign-language publisher'), text: g.deaf_bridge_publisher })
const wayLinks = [
safeHref(g.bridge_training_url) && { label: T('gospelInternet.col.bridgeTraining', 'Training (bridge)'), href: safeHref(g.bridge_training_url) },
safeHref(g.deaf_bridge_url) && { label: T('gospelInternet.drawer.deafLink', 'Signed Scripture'), href: safeHref(g.deaf_bridge_url) },
].filter(Boolean)
const wb = T('gospelInternet.drawer.wbSource', 'World Bank, {year}: the country\'s figure', { year: g.internet_year || '—' })
const conn = [
{ label: T('gospelInternet.col.internet', 'Country online'), text: g.internet_pct == null ? T('gospelInternet.band.nodata', 'No data') : `${n(g.internet_pct)}%`, note: g.internet_pct == null ? '' : wb },
{ label: T('gospelInternet.col.band', 'Internet band'), text: T('gospelInternet.band.' + g.internet_band, g.internet_band), swatch: BAND_COLOR[g.internet_band] },
]
if (g.mobile_per_100 != null) conn.push({ label: T('gospelInternet.col.mobile', 'Mobile per 100'), text: n(g.mobile_per_100), note: T('gospelInternet.drawer.mobileNote', 'Subscriptions per 100 people ({year}); one person may hold several.', { year: g.mobile_year || '—' }) })
if (g.state_internet_women != null || g.state_internet_men != null) {
conn.push({ label: T('gospelInternet.drawer.stateOnline', '{state}: ever used the internet', { state: g.province }),
text: T('gospelInternet.drawer.womenMen', 'women {w} · men {m}', { w: g.state_internet_women == null ? '—' : n(g.state_internet_women) + '%', m: g.state_internet_men == null ? '—' : n(g.state_internet_men) + '%' }),
note: T('gospelInternet.drawer.nfhsNote', 'NFHS-5, 2019-21, ages 15-49: the state\'s figure, not the group\'s.') })
}
if (g.top_platform) conn.push({ label: T('gospelInternet.col.platform', 'Top platform (country)'), text: g.top_platform_pct != null ? `${g.top_platform} (${n(g.top_platform_pct)}%)` : g.top_platform, note: T('gospelInternet.drawer.platformNote', 'Share of web referrals among tracked social platforms in the country (StatCounter). Messaging apps such as WhatsApp are not measured.') })
return [
{ id: 'own', title: T('gospelInternet.drawer.own', 'In their own language'),
lead: { dot: g.access_detail, text: T('gospelInternet.detail.' + g.access_detail, g.access_detail) },
lines: g.scripture ? [{ label: T('gospelInternet.kind.scripture', 'Scripture'), text: g.scripture + (g.translated_by ? ` — ${g.translated_by}` : '') }] : [],
links: own,
note: g.access_detail === 'none' ? T('gospelInternet.drawer.noneNote', 'Nothing found in their own language in the sources checked. That is not a claim that nothing exists.') : T('gospelInternet.drawer.foundNote', 'Found in the sources checked.') },
{ id: 'way', title: T('gospelInternet.drawer.way', 'Way in'), lines: wayLines, links: wayLinks,
note: wayLines.length ? T('gospelInternet.drawer.wayNote', 'A neighbouring language, not a recommendation: whether a group speaks it is a question for someone on the ground.') : '' },
{ id: 'conn', title: T('gospelInternet.drawer.conn', 'Connectivity'), lines: conn, links: [] },
].filter((s) => s.lines.length || s.links.length || s.lead)
})
const copied = ref('')
async function copyLink() {
const url = board.groupLink(r.value.slug)
try { await navigator.clipboard.writeText(url); copied.value = T('gospelInternet.drawer.copied', 'Link copied') }
catch (_) { copied.value = T('messages.copyFailed', 'Copy failed — press Ctrl/Cmd-C') + ' ' + url }
setTimeout(() => { copied.value = '' }, 4000)
}
function onKey(e) { if (e.key === 'Escape') board.closeGroup() }
useShadowStyles(
'.gid{position:absolute;inset-block:0;inset-inline-end:0;z-index:30;width:min(380px,100%);background:#fff;color:#3b463d;' +
'border-inline-start:1px solid #d1d4cd;box-shadow:-8px 0 24px rgba(0,0,0,.10);display:flex;flex-direction:column;' +
'font:13px/1.45 Roboto,-apple-system,"Segoe UI",sans-serif}' +
'.gid-head{display:flex;align-items:flex-start;gap:10px;padding:14px 16px 10px;border-bottom:1px solid #e6e8e3}' +
'.gid-name{font-size:17px;font-weight:700;line-height:1.25;margin:0}' +
'.gid-sub{font-size:12px;color:#6d766e;margin-top:3px}' +
'.gid-x{margin-inline-start:auto;border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:8px;min-width:32px;min-height:32px;cursor:pointer;font-size:14px}' +
'.gid-body{overflow-y:auto;padding:4px 16px 16px;flex:1}' +
'.gid-notice{margin:10px 0 4px;padding:7px 9px;border-radius:8px;background:#f3f3f1;border:1px solid #e1e4de;font-size:11.5px;color:#4d564f}' +
'.gid-sec{padding:10px 0;border-bottom:1px solid #eef0ec}' +
'.gid-h{font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:#6d766e;margin:0 0 6px}' +
'.gid-lead{display:flex;align-items:center;gap:7px;font-weight:600;margin-bottom:4px}' +
'.gid-dot{width:11px;height:11px;border-radius:50%;box-sizing:border-box;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.2);flex:none}' +
'.gid-sw{display:inline-block;width:10px;height:10px;border-radius:2px;margin-inline-end:5px;vertical-align:-1px}' +
'.gid-line{display:flex;justify-content:space-between;gap:12px;padding:3px 0}' +
'.gid-line b{font-weight:600;text-align:end}' +
'.gid-lnote{font-size:11px;color:#6d766e;margin:-1px 0 4px}' +
'.gid-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}' +
'.gid-links a{display:inline-flex;align-items:center;min-height:28px;padding:2px 10px;border-radius:999px;border:1px solid #92b195;color:#3b463d;text-decoration:none;font-size:12.5px}' +
'.gid-links a:hover{background:#e3ece2}' +
'.gid-note{font-size:11.5px;color:#6d766e;margin-top:6px}' +
'.gid-foot{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:12px 16px;border-top:1px solid #e6e8e3;background:#f9faf8}' +
'.gid-pray{display:inline-flex;align-items:center;min-height:32px;padding:4px 14px;border-radius:8px;background:#3b463d;color:#fff;text-decoration:none;font-weight:600}' +
'.gid-copy{border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:8px;min-height:32px;padding:4px 12px;font:inherit;cursor:pointer}' +
'.gid-praying{font-size:12px;color:#6d766e;flex-basis:100%}' +
'.gid-x:focus-visible,.gid-copy:focus-visible,.gid-links a:focus-visible,.gid-pray:focus-visible{outline:2px solid #92b195;outline-offset:2px}',
'gi-drawer',
)
</script>
<template>
<aside v-if="r" class="gid" role="dialog" :aria-label="r.people_group" data-region="drawer" @keydown="onKey">
<div class="gid-head">
<div>
<h2 class="gid-name"><bdi>{{ r.people_group }}</bdi></h2>
<div class="gid-sub">
<bdi>{{ r.country }}</bdi><template v-if="r.province"> · <bdi>{{ r.province }}</bdi> ({{ T('gospelInternet.drawer.approx', 'approximate') }})</template>
· <bdi>{{ r.language }}</bdi> ({{ r.iso639_3 }})
<template v-if="r.population != null"> · {{ T('gospelInternet.drawer.people', '{n} people', { n: n(r.population) }) }}</template>
<template v-if="r.religion"> · <bdi>{{ r.religion }}</bdi></template>
</div>
</div>
<button ref="closeBtn" type="button" class="gid-x" :aria-label="T('controls.close', 'Close')" @click="board.closeGroup()">✕</button>
</div>
<div class="gid-body">
<p class="gid-notice" data-notice="approximate-pin">
{{ T('gospelInternet.notice.pin', 'The dot marks where this people is found in {country}, not a town.', { country: r.country }) }}
<template v-if="r.shares_point"> {{ T('gospelInternet.notice.shares', 'It shares its point with {n} other group(s).', { n: r.shares_point }) }}</template>
</p>
<section v-for="s in sections" :key="s.id" class="gid-sec" :data-section="s.id">
<h3 class="gid-h">{{ s.title }}</h3>
<div v-if="s.lead" class="gid-lead"><span class="gid-dot" :style="{ background: DETAIL_FILL[s.lead.dot], borderColor: DETAIL_RIM[s.lead.dot] }"></span>{{ s.lead.text }}</div>
<template v-for="(l, i) in s.lines" :key="i">
<div class="gid-line"><span>{{ l.label }}</span><b><span v-if="l.swatch" class="gid-sw" :style="{ background: l.swatch }"></span><bdi>{{ l.text }}</bdi></b></div>
<div v-if="l.note" class="gid-lnote">{{ l.note }}</div>
</template>
<div v-if="s.links.length" class="gid-links">
<a v-for="k in s.links" :key="k.href" :href="k.href" target="_blank" rel="noopener noreferrer">{{ k.label }}</a>
</div>
<p v-if="s.note" class="gid-note">{{ s.note }}</p>
</section>
</div>
<div class="gid-foot">
<a v-if="safeHref(r.pray_url)" class="gid-pray" :href="safeHref(r.pray_url)" target="_blank" rel="noopener noreferrer">{{ T('buttons.prayForThem', 'Pray for them') }}</a>
<button type="button" class="gid-copy" @click="copyLink">{{ copied || T('gospelInternet.drawer.copyLink', 'Copy link to this group') }}</button>
<span v-if="r.people_praying != null" class="gid-praying">{{ T('gospelInternet.drawer.praying', '{n} people praying on DOXA', { n: n(r.people_praying) }) }}</span>
</div>
</aside>
</template>

View file

@ -0,0 +1,145 @@
<script setup>
/**
* GiFacetRail.vue — the left rail: every facet with its counts UNDER THE OTHER FACETS.
*
* ★ GLUE, NOT THE KIT'S RAIL. SPEC-GOSPEL-INTERNET-DASHBOARD says "comes from the kit's facet rail; do
* not re-implement it". The kit's rail is landing 4 (simpleresearch moved it forward for this board);
* until it reaches main this is the thinnest rail that honours its contract, and it is deleted the day
* the kit's lands:
* - every write is bus.toggleFilterValue / setFilter (multi-value OR-within-key, D1; clearing cascades);
* - every count is model.facetCounts, which is the bus's own rowMatchesFilters with this facet's key
* left out — so the number beside an option IS the number of rows the map and grid show after the
* click (the "five numbers agree" invariant), not a second computation that could drift;
* - values are CODES; the label is looked up for display only.
*/
import { ref, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { facetCounts, ACCESS_LEVELS, ACCESS_DETAILS, BANDS, DETAIL_FILL, DETAIL_RIM, BAND_COLOR, LEVEL_COLOR } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const LONG = 8 // long facets show their top N until expanded
const expanded = ref({})
const find = ref({ country: '', language: '' })
// Re-derive on any filter/search/data change. facetCounts reads filterSet through the bus predicate.
const tick = computed(() => { for (const k of Object.keys(bus.filterSet)) void bus.filterSet[k]; return [board.all.value, bus.searchTerm.value] })
const counts = (key, opts) => { void tick.value; return facetCounts(board.all.value, bus, key, { term: bus.searchTerm.value, ...opts }) }
const nameOf = computed(() => {
const country = {}, language = {}
for (const r of board.all.value) { country[r.iso3] = r.country; language[r.iso639_3] = r.language }
return { country, language }
})
const sections = computed(() => {
const out = []
out.push({ id: 'access', title: T('gospelInternet.facet.access', 'Access in their own language'), groups: [
{ key: 'access_level', rows: counts('access_level', { order: ACCESS_LEVELS }), label: (v) => T('gospelInternet.level.' + v, v), sw: (v) => ({ background: LEVEL_COLOR[v] }) },
{ key: 'access_detail', sub: true, rows: counts('access_detail', { order: ACCESS_DETAILS }), label: (v) => T('gospelInternet.detail.' + v, v), dot: (v) => ({ background: DETAIL_FILL[v], borderColor: DETAIL_RIM[v] }) },
] })
out.push({ id: 'band', title: T('gospelInternet.facet.band', 'Country internet use'), note: T('gospelInternet.facet.bandNote', 'The country\'s figure (World Bank), never the group\'s.'), groups: [
{ key: 'band', rows: counts('band', { order: BANDS }), label: (v) => T('gospelInternet.band.' + v, v), sw: (v) => ({ background: BAND_COLOR[v] }) },
] })
out.push({ id: 'way_in', title: T('gospelInternet.facet.wayIn', 'Way in when nothing is in their language'), groups: [
{ key: 'way_in', rows: counts('way_in', { order: ['bridge', 'candidate', 'deaf'] }), label: (v) => T('gospelInternet.wayIn.' + v, v) },
] })
out.push({ id: 'region', title: T('gospelInternet.facet.region', 'Region'), groups: [
{ key: 'region', rows: counts('region'), label: (v) => v },
] })
out.push({ id: 'country', title: T('gospelInternet.facet.country', 'Country'), find: 'country', long: true, groups: [
{ key: 'country', rows: counts('country'), label: (v) => nameOf.value.country[v] || v },
] })
const prov = counts('province')
if (prov.length) {
out.push({ id: 'province', title: T('gospelInternet.facet.province', 'India and China: state or province'),
note: T('gospelInternet.facet.provinceNote', 'Approximate: placed from where a people is found, not a town.'), long: true, groups: [
{ key: 'province', rows: prov, label: (v) => v },
] })
}
out.push({ id: 'language', title: T('gospelInternet.facet.language', 'Language'), find: 'language', long: true, groups: [
{ key: 'language', rows: counts('language'), label: (v) => `${nameOf.value.language[v] || v} (${v})` },
] })
out.push({ id: 'religion', title: T('gospelInternet.facet.religion', 'Religion'), long: true, groups: [
{ key: 'religion', rows: counts('religion'), label: (v) => v },
] })
return out
})
function visibleRows(sec, g) {
let rows = g.rows
const q = sec.find ? find.value[sec.find].trim().toLowerCase() : ''
if (q) rows = rows.filter((r) => g.label(r.value).toLowerCase().includes(q) || String(r.value).toLowerCase() === q)
// A picked value always shows, even past the fold — a filter the reader cannot see is a trap.
if (sec.long && !expanded.value[sec.id] && !q) {
const top = rows.slice(0, LONG)
const picked = rows.filter((r) => bus.isFilterActive(g.key, r.value) && !top.includes(r))
return top.concat(picked)
}
return rows
}
const isOn = (key, v) => { void tick.value; return bus.isFilterActive(key, v) }
const anyOn = (sec) => { void tick.value; return sec.groups.some((g) => g.key in bus.filterSet) }
function clearSection(sec) { for (const g of sec.groups) bus.setFilter(g.key, null) }
const fmt = (n) => (Number(n) || 0).toLocaleString()
useShadowStyles(
'.gif{height:100%;overflow-y:auto;padding:10px 12px 18px;box-sizing:border-box;background:#fff;color:#3b463d;' +
'font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;scrollbar-width:thin}' +
'.gif-sec{border-bottom:1px solid #e6e8e3;padding:9px 0 10px}' +
'.gif-h{display:flex;align-items:center;gap:6px;margin:0 0 6px;font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:#3b463d}' +
'.gif-clear{margin-inline-start:auto;border:0;background:none;color:#6d766e;font:inherit;font-size:11px;font-weight:500;text-transform:none;cursor:pointer;min-height:24px;padding:0 4px;text-decoration:underline}' +
'.gif-note{font-size:11px;color:#6d766e;margin:-2px 0 6px}' +
'.gif-find{width:100%;box-sizing:border-box;border:1px solid #d1d4cd;border-radius:6px;padding:5px 8px;font:inherit;font-size:12px;margin-bottom:5px;background:#f3f3f1;color:#3b463d}' +
'.gif-opt{display:flex;align-items:center;gap:7px;width:100%;min-height:26px;padding:2px 6px;border:0;border-radius:6px;background:none;color:inherit;' +
'font:inherit;text-align:start;cursor:pointer}' +
'.gif-opt:hover{background:#f3f3f1}' +
'.gif-opt:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gif-opt[aria-pressed="true"]{background:#e3ece2;font-weight:600}' +
'.gif-opt.sub{padding-inline-start:20px;font-size:12.5px}' +
'.gif-opt.zero{color:#9aa19b}' +
'.gif-box{width:13px;height:13px;border:1.5px solid #6d766e;border-radius:3px;flex:none;box-sizing:border-box;display:grid;place-items:center;font-size:10px;line-height:1;color:#fff}' +
'.gif-opt[aria-pressed="true"] .gif-box{background:#3b463d;border-color:#3b463d}' +
'.gif-lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
'.gif-n{font-variant-numeric:tabular-nums;color:#6d766e;font-size:12px}' +
'.gif-sw{width:10px;height:10px;border-radius:2px;flex:none}' +
'.gif-dot{width:10px;height:10px;border-radius:50%;flex:none;box-sizing:border-box;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.2)}' +
'.gif-more{border:0;background:none;color:#4f6b52;font:inherit;font-size:12px;cursor:pointer;padding:3px 6px;min-height:24px;text-decoration:underline}',
'gi-facets',
)
</script>
<template>
<nav class="gif" :aria-label="T('gospelInternet.facet.aria', 'Filters')">
<section v-for="sec in sections" :key="sec.id" class="gif-sec" :data-facet="sec.id">
<h3 class="gif-h">
<span>{{ sec.title }}</span>
<button v-if="anyOn(sec)" type="button" class="gif-clear" @click="clearSection(sec)">{{ T('controls.clear', 'Clear') }}</button>
</h3>
<p v-if="sec.note" class="gif-note">{{ sec.note }}</p>
<input v-if="sec.find" v-model="find[sec.find]" class="gif-find" type="search"
:placeholder="T('gospelInternet.facet.find', 'Find…')" :aria-label="T('gospelInternet.facet.findIn', 'Find in {what}', { what: sec.title })" />
<template v-for="g in sec.groups" :key="g.key">
<button v-for="r in visibleRows(sec, g)" :key="g.key + ':' + r.value" type="button"
class="gif-opt" :class="{ sub: g.sub, zero: !r.groups }"
:aria-pressed="isOn(g.key, r.value) ? 'true' : 'false'"
:data-value="r.value"
@click="bus.toggleFilterValue(g.key, r.value)">
<span class="gif-box" aria-hidden="true">{{ isOn(g.key, r.value) ? '✓' : '' }}</span>
<span v-if="g.sw" class="gif-sw" :style="g.sw(r.value)" aria-hidden="true"></span>
<span v-if="g.dot" class="gif-dot" :style="g.dot(r.value)" aria-hidden="true"></span>
<span class="gif-lbl" :title="g.label(r.value)"><bdi>{{ g.label(r.value) }}</bdi></span>
<span class="gif-n">{{ fmt(r.groups) }}</span>
</button>
<button v-if="sec.long && g.rows.length > LONG && !find[sec.find || '']" type="button" class="gif-more"
@click="expanded[sec.id] = !expanded[sec.id]">
{{ expanded[sec.id] ? T('gospelInternet.facet.fewer', 'Show fewer') : T('gospelInternet.facet.all', 'Show all {n}', { n: fmt(g.rows.length) }) }}
</button>
</template>
</section>
</nav>
</template>

View file

@ -0,0 +1,201 @@
<script setup>
/**
* GiGrid.vue — the grid under the map: one row per people group in scope, sortable, selectable, with a
* column picker and the CSV export.
*
* ★ GLUE to the kit's table contract (DashboardDataTable is kit landing 5): selection is the bus's
* (checkbox = toggleSelect, row click = open + select one), hover is the bus's (row hover -> the pin is
* outlined; a pin hovered on the map lights its row), the row set is the board's `sorted` — the same
* filtered rows the map and the charts read. Windowed rendering: only the rows in view are in the DOM,
* so 2,107 rows cost ~30 row elements.
*
* CSV (glue to kit useCsvExport, landing 3 + ef74b8ca): selection > filter > all; the columns that are
* ON, in their order, raw codes (never translated); header on line 1, provenance LAST with sources,
* list, filters and date; clipboard first (works in a sandboxed iframe), then a file download.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { buildColumns } from '../columns.js'
import { buildCsv, exportScope, safeHref, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const columns = computed(() => buildColumns(T))
const shown = ref(null) // Set of keys ON; null until columns are known
const on = computed(() => shown.value || new Set(columns.value.filter((c) => !c.hidden).map((c) => c.key)))
const visibleCols = computed(() => columns.value.filter((c) => on.value.has(c.key)))
function toggleCol(key) {
const s = new Set(on.value)
s.has(key) ? s.delete(key) : s.add(key)
if (s.size) shown.value = s
}
const pickerOpen = ref(false)
const rows = computed(() => board.sorted.value)
const total = computed(() => board.all.value.length)
const selCount = computed(() => { void bus.selectionVersion.value; return bus.selectionSet.size })
const isSel = (slug) => { void bus.selectionVersion.value; return bus.selectionSet.has(slug) }
const hover = computed(() => bus.hoverKey.value)
function sortBy(c) {
const cur = board.sort.value
board.sort.value = cur.key === c.key ? { key: c.key, dir: -cur.dir } : { key: c.key, dir: c.num ? -1 : 1 }
}
const ariaSort = (c) => board.sort.value.key !== c.key ? 'none' : board.sort.value.dir > 0 ? 'ascending' : 'descending'
// ── windowing ─────────────────────────────────────────────────────────────────────────────────────
const ROW_H = 32
const scroller = ref(null)
const scrollTop = ref(0)
const viewH = ref(400)
const first = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 6))
const last = computed(() => Math.min(rows.value.length, Math.ceil((scrollTop.value + viewH.value) / ROW_H) + 6))
const slice = computed(() => rows.value.slice(first.value, last.value))
let ro = null
onMounted(() => {
if (scroller.value && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => { viewH.value = scroller.value ? scroller.value.clientHeight : 400 })
ro.observe(scroller.value)
}
})
onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
// A new scope starts at the top: row 900 of the old scope means nothing in the new one.
watch(() => board.filtered.value, () => { if (scroller.value) scroller.value.scrollTop = 0; scrollTop.value = 0 })
// An opened group scrolls into the grid (from a pin click), once — never on hover.
watch(() => board.openSlug.value, (slug) => {
if (!slug || !scroller.value) return
const i = rows.value.findIndex((r) => r.slug === slug)
if (i < 0) return
const top = i * ROW_H
if (top < scroller.value.scrollTop || top > scroller.value.scrollTop + viewH.value - ROW_H * 2) scroller.value.scrollTop = Math.max(0, top - ROW_H * 2)
})
function onRowKey(e, r) {
if (e.key === 'Enter') board.openGroup(r.slug)
else if (e.key === ' ') { e.preventDefault(); bus.toggleSelect(r.slug) }
}
const cell = (c, r) => (c.show ? c.show(r) : r[c.key] ?? '')
// ── CSV ─────────────────────────────────────────────────────────────────────────────────────────────
const status = ref('')
function csvText() {
const { scope, rows: picked } = exportScope(board.all.value, board.sorted.value, bus.selectionSet)
const filters = Object.entries(bus.filterSet).map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('|') : v}`).join('; ') || 'none'
const src = (board.meta.value.sources || []).join('; ')
const date = (board.meta.value.inputs_generated && board.meta.value.inputs_generated['doxa-gospel-access']) || ''
const provenance = `# Gospel x Internet export — scope: ${scope} (${picked.length}); list: ${board.listId.value}; filters: ${filters}` +
`${bus.searchTerm.value ? '; search: ' + bus.searchTerm.value : ''}; data: ${date}; sources: ${src}` +
'; internet figures are the country\'s (or the Indian state\'s), never the group\'s; state/province is approximate'
const cols = visibleCols.value.map((c) => ({ key: c.key, header: c.header, csv: c.csv }))
return { csv: buildCsv(picked, cols, { provenance }), n: picked.length }
}
function exportCsv() {
const { csv, n } = csvText()
let clipboard = false, downloaded = false
try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) {}
try {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
const a = document.createElement('a'); a.href = url; a.download = 'gospel-internet.csv'; a.style.display = 'none'
document.body.appendChild(a); a.click(); downloaded = true
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) {}
try { window.__giLastCsv = csv; window.__giExportStatus = { rows: n, clipboard, downloaded } } catch (_) {}
status.value = T('gospelInternet.grid.exported', '{n} rows exported (copied and downloaded).', { n: n.toLocaleString() })
setTimeout(() => { status.value = '' }, 4000)
}
const exportLabel = computed(() => selCount.value ? T('dashboard.export_selection', 'Export this selection') : T('dashboard.export_csv', 'Export CSV'))
const fmt = (n) => (Number(n) || 0).toLocaleString()
useShadowStyles(
'.gig{display:flex;flex-direction:column;height:100%;min-height:0;background:#fff;color:#3b463d;font:13px/1.3 Roboto,-apple-system,"Segoe UI",sans-serif}' +
'.gig-bar{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:6px 10px;border-bottom:1px solid #d1d4cd;background:#f3f3f1}' +
'.gig-count{font-weight:600;font-variant-numeric:tabular-nums}' +
'.gig-search{flex:1 1 180px;max-width:320px;border:1px solid #d1d4cd;border-radius:6px;padding:5px 9px;font:inherit;font-size:12.5px;background:#fff;color:#3b463d}' +
'.gig-btn{border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;padding:4px 10px;min-height:28px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gig-btn:hover{border-color:#92b195}' +
'.gig-btn.primary{background:#3b463d;border-color:#3b463d;color:#fff}' +
'.gig-btn:focus-visible,.gig-th button:focus-visible,.gig-tr:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gig-status{font-size:12px;color:#4f6b52}' +
'.gig-picker{position:relative}' +
'.gig-menu{position:absolute;inset-block-end:calc(100% + 4px);inset-inline-end:0;z-index:20;background:#fff;border:1px solid #d1d4cd;border-radius:8px;' +
'box-shadow:0 6px 20px rgba(0,0,0,.12);padding:6px;max-height:320px;overflow:auto;min-width:230px}' +
'.gig-menu label{display:flex;gap:7px;align-items:center;padding:3px 6px;min-height:24px;font-size:12.5px;cursor:pointer}' +
'.gig-scroll{flex:1;min-height:0;overflow:auto;position:relative;scrollbar-width:thin}' +
'.gig-table{display:grid;min-width:max-content}' +
'.gig-head{position:sticky;top:0;z-index:3;display:flex;background:#fff;border-bottom:1px solid #d1d4cd}' +
'.gig-th,.gig-td{box-sizing:border-box;padding:0 8px;display:flex;align-items:center;min-width:0;flex:none}' +
'.gig-th{height:34px;font-size:11.5px;font-weight:700;color:#6d766e;text-transform:uppercase;letter-spacing:.02em}' +
'.gig-th button{all:unset;cursor:pointer;display:flex;gap:4px;align-items:center;width:100%;min-height:24px}' +
'.gig-td{height:32px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}' +
'.gig-td > span{overflow:hidden;text-overflow:ellipsis}' +
'.gig-td.num,.gig-th.num{justify-content:flex-end;font-variant-numeric:tabular-nums}' +
'.gig-tr{display:flex;position:absolute;inset-inline:0;border-bottom:1px solid #eef0ec;cursor:pointer;background:#fff}' +
'.gig-tr:hover,.gig-tr.hov{background:#f6efe2}' +
'.gig-tr.sel{background:#e3ece2}' +
'.gig-tr.open{box-shadow:inset 3px 0 0 #3b463d}' +
'.gig-sticky{position:sticky;inset-inline-start:0;z-index:1;background:inherit;font-weight:600}' +
'.gig-chk{width:34px;justify-content:center}' +
'.gig-chk input{width:15px;height:15px;margin:0;cursor:pointer;accent-color:#3b463d}' +
'.gig-dot{width:10px;height:10px;border-radius:50%;box-sizing:border-box;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.2);flex:none;margin-inline-end:6px}' +
'.gig-sw{width:10px;height:10px;border-radius:2px;flex:none;margin-inline-end:6px;opacity:.85}' +
'.gig-td a{color:#4f6b52}' +
'.gig-empty{padding:24px;text-align:center;color:#6d766e}',
'gi-grid',
)
</script>
<template>
<section class="gig" data-region="grid" :aria-label="T('gospelInternet.grid.aria', 'People groups in scope')">
<div class="gig-bar">
<span class="gig-count" data-grid-total>{{ T('gospelInternet.grid.count', '{n} of {total} people groups', { n: fmt(rows.length), total: fmt(total) }) }}</span>
<input class="gig-search" type="search" :value="bus.searchTerm.value" @input="bus.setSearch($event.target.value)"
:placeholder="T('grid.search_people_or_countries', 'Search people groups or countries…')"
:aria-label="T('grid.search_people_or_countries', 'Search people groups or countries…')" />
<button v-if="selCount" type="button" class="gig-btn" @click="bus.clearSelection()">{{ T('buttons.clearSelection', 'Clear selection') }} ({{ fmt(selCount) }})</button>
<span class="gig-status" role="status">{{ status }}</span>
<span style="flex:1"></span>
<div class="gig-picker">
<button type="button" class="gig-btn" :aria-expanded="pickerOpen ? 'true' : 'false'" @click="pickerOpen = !pickerOpen">{{ T('gospelInternet.grid.columns', 'Columns') }}</button>
<div v-if="pickerOpen" class="gig-menu" role="group" :aria-label="T('gospelInternet.grid.columns', 'Columns')">
<label v-for="c in columns" :key="c.key"><input type="checkbox" :checked="on.has(c.key)" @change="toggleCol(c.key)" /> {{ c.label }}</label>
</div>
</div>
<button type="button" class="gig-btn primary" data-export-csv @click="exportCsv">{{ exportLabel }}</button>
</div>
<div ref="scroller" class="gig-scroll" @scroll="scrollTop = $event.target.scrollTop" role="table" :aria-rowcount="rows.length + 1">
<div class="gig-table">
<div class="gig-head" role="row">
<div class="gig-th gig-chk" role="columnheader"><span class="gig-visually-hidden" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">{{ T('dashboard.selected', 'Selected') }}</span></div>
<div v-for="c in visibleCols" :key="c.key" class="gig-th" :class="{ num: c.num, 'gig-sticky': c.sticky }" :style="{ width: c.w + 'px' }"
role="columnheader" :aria-sort="ariaSort(c)">
<button type="button" @click="sortBy(c)">{{ c.label }}<span aria-hidden="true">{{ board.sort.value.key === c.key ? (board.sort.value.dir > 0 ? '▲' : '▼') : '' }}</span></button>
</div>
</div>
<div :style="{ height: rows.length * ROW_H + 'px', position: 'relative' }" role="rowgroup">
<div v-for="(r, i) in slice" :key="r.slug" class="gig-tr" role="row" tabindex="0"
:class="{ sel: isSel(r.slug), hov: hover === r.slug, open: board.openSlug.value === r.slug }"
:style="{ top: (first + i) * ROW_H + 'px' }" :data-slug="r.slug"
@click="board.openGroup(r.slug)" @keydown="onRowKey($event, r)"
@mouseenter="bus.setHover(r.slug)" @mouseleave="bus.setHover(null)">
<div class="gig-td gig-chk" role="cell" @click.stop>
<input type="checkbox" :checked="isSel(r.slug)" :aria-label="T('gospelInternet.grid.selectRow', 'Select {name}', { name: r.people_group })" @change="bus.toggleSelect(r.slug)" />
</div>
<div v-for="c in visibleCols" :key="c.key" class="gig-td" :class="{ num: c.num, 'gig-sticky': c.sticky }" :style="{ width: c.w + 'px' }" role="cell">
<span v-if="c.dot" class="gig-dot" :style="{ background: DETAIL_FILL[r.access_detail], borderColor: DETAIL_RIM[r.access_detail] }" aria-hidden="true"></span>
<span v-if="c.swatch" class="gig-sw" :style="{ background: BAND_COLOR[r.internet_band] }" aria-hidden="true"></span>
<a v-if="c.url && safeHref(r[c.key])" :href="safeHref(r[c.key])" target="_blank" rel="noopener noreferrer" @click.stop>{{ c.linkText || r[c.key] }}</a>
<span v-else-if="!c.url" :title="String(cell(c, r))"><bdi>{{ cell(c, r) }}</bdi></span>
</div>
</div>
</div>
<div v-if="!rows.length && !board.loading.value" class="gig-empty">{{ T('gospelInternet.grid.empty', 'No people groups match these filters.') }}</div>
</div>
</div>
</section>
</template>

View file

@ -0,0 +1,410 @@
<script setup>
/**
* GiMap.vue — the board's map: countries shaded by internet band, India's states and China's provinces
* outlined (and shaded on request), and one pin per people group.
*
* ONE ENGINE SWITCH. The engine comes from resolveMapProvider (profile-config provider / token), the
* style from the style registry ('paper' = Mapbox light / OpenFreeMap positron), labels in the reader's
* language, RTL shaping installed before `new Map`. Nothing below branches on the engine: every layer
* and expression here is valid on both Mapbox GL and MapLibre (fill, line, circle; match/step/case;
* feature-state with promoteId). No symbol layers, so no glyph font to 404 on OpenFreeMap.
*
* THE KIT'S THREE CHANNELS, and what each is allowed to do here (SPEC-DASHBOARD-KIT section 5.2, D3):
* filterSet NARROWS — pins outside the scope FADE (never vanish), countries with no group in scope
* fade; clicking a country or province toggles that facet (a map region is a filter).
* selection MARKS — a ring; and ONE camera move per committed change, debounced, a JUMP under
* reduced motion (glue to useSelectionCamera's contract until kit landing 3 reaches main).
* hoverKey POINTS — an outline on the hovered pin, from the grid or the map. It never flies,
* never selects, never filters.
*
* HONESTY: a pin marks where a people is found within a country, not a town (the legend says so, and
* how many share a point); the band is the COUNTRY's figure; a state's NFHS-5 figure is the state's.
*/
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, markRaw } from 'vue'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl, engineUiLocale } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
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 ShareButton from '@map/components/map-controls/ShareButton.vue'
import { useBoardState, EMBED_PATH } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { ACCESS_DETAILS, BANDS, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const provider = resolveMapProvider(board.profileConfig)
const mapEl = ref(null)
const mapRef = shallowRef(null)
const err = ref('')
const ready = ref(false)
// Province shading: 'zero' = groups with nothing found in their own language (from the rows in scope),
// 'women' / 'men' = NFHS-5 ever used the internet (India only; China has no verified provincial source).
const provinceMode = ref('zero')
const legendOpen = ref(true)
let unmounted = false
let adm1Table = null // { [shapeName]: { women, men } } India, NFHS-5
const nfhsAttribution = ref('')
const readerLang = () => {
const pc = board.profileConfig.value || {}
if (pc.locale || pc.lang) return String(pc.locale || pc.lang)
try { return new URLSearchParams(location.search).get('lang') || document.documentElement.lang || 'en' } catch { return 'en' }
}
// ── pins ────────────────────────────────────────────────────────────────────────────────────────────
function pinFC() {
const inScope = new Set(board.filtered.value.map((r) => r.slug))
const feats = []
for (const r of board.all.value) {
if (!Number.isFinite(r.lng) || !Number.isFinite(r.lat)) continue
feats.push({
type: 'Feature',
properties: { slug: r.slug, d: r.access_detail, pop: Number(r.population) || 0, m: inScope.has(r.slug) ? 1 : 0,
// "Nothing in own language" draws on top: a hole must never hide under a dot.
z: r.access_detail === 'none' ? 2 : 1 },
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
})
}
return { type: 'FeatureCollection', features: feats }
}
const matchExpr = (table, fallback) => ['match', ['get', 'd'], ...ACCESS_DETAILS.flatMap((d) => [d, table[d]]), fallback]
// Sized by population on a square-root scale (area ~ people), clamped so a 100-person group is still a
// target and a 50-million group does not blanket a state.
// A zoom interpolate must be the TOP-LEVEL expression on both engines, so the ring and hover layers get
// their own (+k on every stop) rather than wrapping this one in a '+'.
const radiusPlus = (k) => ['interpolate', ['linear'], ['zoom'],
1, ['interpolate', ['linear'], ['sqrt', ['get', 'pop']], 0, 2.2 + k, 1000, 3.4 + k, 7000, 6.5 + k],
6, ['interpolate', ['linear'], ['sqrt', ['get', 'pop']], 0, 4 + k, 1000, 6.5 + k, 7000, 12 + k]]
const radius = radiusPlus(0)
function addPins(map) {
map.addSource('gi-pins', { type: 'geojson', data: pinFC() })
map.addLayer({ id: 'gi-pins', type: 'circle', source: 'gi-pins', paint: {
'circle-radius': radius,
'circle-color': matchExpr(DETAIL_FILL, '#92b195'),
'circle-stroke-color': matchExpr(DETAIL_RIM, '#ffffff'),
'circle-stroke-width': ['case', ['==', ['get', 'd'], 'none'], 2, 1],
'circle-opacity': ['case', ['==', ['get', 'm'], 1], 0.95, 0.12],
'circle-stroke-opacity': ['case', ['==', ['get', 'm'], 1], 1, 0.15],
}, layout: { 'circle-sort-key': ['+', ['get', 'z'], ['get', 'm']] } })
// selection ring (MARKS) and hover outline (POINTS): separate layers, so neither repaints the pins
map.addLayer({ id: 'gi-pins-sel', type: 'circle', source: 'gi-pins', filter: ['in', ['get', 'slug'], ['literal', []]], paint: {
'circle-radius': radiusPlus(4), 'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': '#3b463d', 'circle-stroke-width': 2.5,
} })
map.addLayer({ id: 'gi-pins-hover', type: 'circle', source: 'gi-pins', filter: ['==', ['get', 'slug'], ''], paint: {
'circle-radius': radiusPlus(3), 'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': '#e8a33d', 'circle-stroke-width': 2.5,
} })
map.on('click', 'gi-pins', (e) => {
const f = e.features && e.features[0]
if (f) { e.originalEvent && (e.originalEvent.__giPin = true); board.openGroup(f.properties.slug) }
})
map.on('mousemove', 'gi-pins', (e) => {
map.getCanvas().style.cursor = 'pointer'
const f = e.features && e.features[0]
if (f) bus.setHover(f.properties.slug)
})
map.on('mouseleave', 'gi-pins', () => { map.getCanvas().style.cursor = ''; bus.setHover(null) })
}
// ── countries by band (promoteId iso3, so the scope fade is a feature-state, not a re-upload) ─────────
const bandByIso = () => {
const m = {}
for (const r of board.all.value) m[r.iso3] = r.internet_band
return m
}
function addCountries(map, adm0) {
const bands = bandByIso()
const fc = { type: 'FeatureCollection', features: (adm0.features || []).map((f) => ({
type: 'Feature', geometry: f.geometry,
properties: { iso3: f.properties.iso3, band: bands[f.properties.iso3] || '' },
})) }
map.addSource('gi-adm0', { type: 'geojson', data: fc, promoteId: 'iso3' })
map.addLayer({ id: 'gi-adm0-fill', type: 'fill', source: 'gi-adm0', paint: {
'fill-color': ['match', ['get', 'band'], ...BANDS.flatMap((b) => [b, BAND_COLOR[b]]), 'rgba(0,0,0,0)'],
'fill-opacity': ['case', ['==', ['get', 'band'], ''], 0,
['boolean', ['feature-state', 'inScope'], true], 0.42, 0.1],
} })
map.addLayer({ id: 'gi-adm0-line', type: 'line', source: 'gi-adm0', paint: {
'line-color': ['case', ['boolean', ['feature-state', 'picked'], false], '#3b463d', 'rgba(255,255,255,0.7)'],
'line-width': ['case', ['boolean', ['feature-state', 'picked'], false], 1.6, 0.5],
} })
map.on('click', 'gi-adm0-fill', (e) => {
if (e.originalEvent && e.originalEvent.__giPin) return
if (map.queryRenderedFeatures(e.point, { layers: ['gi-pins', 'gi-adm1-fill'].filter((l) => map.getLayer(l)) }).length) return
const iso = e.features && e.features[0] && e.features[0].properties.iso3
if (iso && bands[iso]) bus.toggleFilterValue('country', iso)
})
}
// ── India's states + China's provinces ───────────────────────────────────────────────────────────────
const ADM1_STEPS = ['#f3f3f1', '#e7c9c2', '#c98f83', '#a55a4b', '#7d3b30'] // zero-access groups: 0,1-2,3-5,6-10,11+
const NFHS_STEPS = ['#deebf7', '#9ecae1', '#6baed6', '#3182bd', '#08519c'] // Cities with Internet's ADM1 ramp
function adm1Fill(mode) {
if (mode === 'women' || mode === 'men') {
return ['case', ['==', ['typeof', ['get', mode]], 'number'],
['step', ['get', mode], NFHS_STEPS[0], 25, NFHS_STEPS[1], 40, NFHS_STEPS[2], 55, NFHS_STEPS[3], 70, NFHS_STEPS[4]],
'rgba(0,0,0,0)']
}
return ['step', ['coalesce', ['feature-state', 'zero'], 0], 'rgba(0,0,0,0)', 1, ADM1_STEPS[1], 3, ADM1_STEPS[2], 6, ADM1_STEPS[3], 11, ADM1_STEPS[4]]
}
function addProvinces(map, ind, chn) {
const feats = []
for (const [iso3, geo] of [['IND', ind], ['CHN', chn]]) {
for (const f of (geo && geo.features) || []) {
const name = f.properties.shapeName
const a = iso3 === 'IND' && adm1Table ? adm1Table[name] : null
feats.push({ type: 'Feature', geometry: f.geometry, properties: {
key: `${iso3}|${name}`, iso3, name, women: a && a.women != null ? a.women : null, men: a && a.men != null ? a.men : null,
} })
}
}
map.addSource('gi-adm1', { type: 'geojson', data: { type: 'FeatureCollection', features: feats }, promoteId: 'key' })
map.addLayer({ id: 'gi-adm1-fill', type: 'fill', source: 'gi-adm1', paint: { 'fill-color': adm1Fill(provinceMode.value), 'fill-opacity': 0.78 } }, 'gi-pins')
map.addLayer({ id: 'gi-adm1-line', type: 'line', source: 'gi-adm1', paint: {
'line-color': ['case', ['boolean', ['feature-state', 'picked'], false], '#3b463d', 'rgba(59,70,61,0.45)'],
'line-width': ['case', ['boolean', ['feature-state', 'picked'], false], 1.8, 0.6],
} }, 'gi-pins')
map.on('click', 'gi-adm1-fill', (e) => {
if (e.originalEvent && e.originalEvent.__giPin) return
if (map.queryRenderedFeatures(e.point, { layers: ['gi-pins'] }).length) return
const p = e.features && e.features[0] && e.features[0].properties
if (p) bus.toggleFilterValue('province', p.name)
})
}
function setProvinceMode(mode) {
provinceMode.value = mode
const map = mapRef.value
if (map && map.getLayer('gi-adm1-fill')) map.setPaintProperty('gi-adm1-fill', 'fill-color', adm1Fill(mode))
}
// ── scope -> map (one pass per committed filter change) ───────────────────────────────────────────────
let lastPicked = { country: [], province: [] }
function paintScope() {
const map = mapRef.value
if (!map || !ready.value) return
try { map.getSource('gi-pins').setData(pinFC()) } catch (_) {}
const inCountry = new Set()
const zeroBy = {}
for (const r of board.filtered.value) {
inCountry.add(r.iso3)
if (r.province && r.access_detail === 'none') zeroBy[`${r.iso3}|${r.province}`] = (zeroBy[`${r.iso3}|${r.province}`] || 0) + 1
}
const scoped = Object.keys(bus.filterSet).length > 0 || !!bus.searchTerm.value
for (const iso of new Set(board.all.value.map((r) => r.iso3))) {
try { map.setFeatureState({ source: 'gi-adm0', id: iso }, { inScope: !scoped || inCountry.has(iso) }) } catch (_) {}
}
if (map.getSource('gi-adm1')) {
for (const f of provinceKeys) {
try { map.setFeatureState({ source: 'gi-adm1', id: f }, { zero: zeroBy[f] || 0 }) } catch (_) {}
}
}
// a picked country / province gets a dark outline (the facet made visible where it applies)
const asList = (v) => (v == null ? [] : Array.isArray(v) ? v : [v])
for (const iso of lastPicked.country) try { map.setFeatureState({ source: 'gi-adm0', id: iso }, { picked: false }) } catch (_) {}
for (const k of lastPicked.province) try { map.setFeatureState({ source: 'gi-adm1', id: k }, { picked: false }) } catch (_) {}
const pc = asList(bus.filterSet.country)
const pp = asList(bus.filterSet.province).flatMap((n) => provinceKeys.filter((k) => k.endsWith('|' + n)))
for (const iso of pc) try { map.setFeatureState({ source: 'gi-adm0', id: iso }, { picked: true }) } catch (_) {}
for (const k of pp) try { map.setFeatureState({ source: 'gi-adm1', id: k }, { picked: true }) } catch (_) {}
lastPicked = { country: pc, province: pp }
}
let provinceKeys = []
watch(() => board.filtered.value, paintScope)
// ── selection -> ring + camera (glue to kit useSelectionCamera: debounce 150, one move, jump if reduced) ──
const CAMERA = { DEBOUNCE: 150, DURATION: 1200, PADDING: 70, FIT_MAX_ZOOM: 5.5, SINGLE_ZOOM: 5.2, WIDE_SPAN: 110 }
const reducedMotion = () => { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } catch (_) { return false } }
let camTimer = null
function moveCamera(target) {
const map = mapRef.value
if (!map || !target) return
// Read LIVE, never cached: the preference can change mid-session. Under it we JUMP: same destination,
// zero traversal (jumpTo / fitBounds duration 0 — useMapFly.flyToCoords drops its options).
const duration = reducedMotion() ? 0 : CAMERA.DURATION
try {
if (target.point) {
const z = Math.max(map.getZoom(), CAMERA.SINGLE_ZOOM)
if (duration === 0) map.jumpTo({ center: target.point, zoom: z })
else map.flyTo({ center: target.point, zoom: z, duration, essential: true })
} else {
map.fitBounds(target.bounds, { padding: CAMERA.PADDING, maxZoom: CAMERA.FIT_MAX_ZOOM, duration, essential: true })
}
} catch (_) { /* degenerate bounds: never break the board */ }
}
function boundsOf(rows) {
const pts = rows.filter((r) => Number.isFinite(r.lng) && Number.isFinite(r.lat) && !(r.lng === 0 && r.lat === 0))
if (!pts.length) return null
if (pts.length === 1) return { point: [pts[0].lng, pts[0].lat] }
let w = 180, s = 90, e = -180, n = -90
for (const r of pts) { w = Math.min(w, r.lng); e = Math.max(e, r.lng); s = Math.min(s, r.lat); n = Math.max(n, r.lat) }
if (e - w > CAMERA.WIDE_SPAN) return { bounds: [[-150, -50], [170, 70]] }
if (e - w < 0.01 && n - s < 0.01) return { point: [(w + e) / 2, (s + n) / 2] }
return { bounds: [[w, s], [e, n]] }
}
watch(() => bus.selectionVersion.value, () => {
const map = mapRef.value
if (map && map.getLayer('gi-pins-sel')) map.setFilter('gi-pins-sel', ['in', ['get', 'slug'], ['literal', [...bus.selectionSet]]])
if (camTimer) clearTimeout(camTimer)
camTimer = setTimeout(() => {
camTimer = null
const rows = [...bus.selectionSet].map((s) => board.bySlug.value.get(s)).filter(Boolean)
moveCamera(boundsOf(rows)) // an empty selection holds position: deselecting is not a navigation
}, CAMERA.DEBOUNCE)
})
watch(() => bus.hoverKey.value, (k) => {
const map = mapRef.value
if (map && map.getLayer('gi-pins-hover')) map.setFilter('gi-pins-hover', ['==', ['get', 'slug'], k || ''])
})
// A view change frames its subject once (the province view: India and China).
watch(() => board.view.value, (v) => { if (v === 'province') moveCamera({ bounds: [[68, 8], [123, 45]] }) })
// ── mount ───────────────────────────────────────────────────────────────────────────────────────────
const assetsBase = () => resolveAssetsBase(board.profileConfig.value)
const getJson = (path) => fetch(`${assetsBase()}/${path}`).then((r) => { if (!r.ok) throw new Error(`${path} HTTP ${r.status}`); return r.json() })
onMounted(async () => {
try { await ensureEngineScript(provider) } catch (_) { /* reported just below */ }
const gl = engineGlobal(provider)
if (unmounted) return
if (!gl) {
const engine = provider === 'maplibre' ? 'MapLibre' : 'Mapbox'
err.value = T('messages.engineMissing', `The ${engine} map engine did not load.`, { engine })
return
}
installRtlTextPlugin(gl)
const adm0P = getJson('boundaries/world-adm0.geojson')
const indP = getJson('boundaries/gb-IND-ADM1-full.geojson').catch(() => null)
const chnP = getJson('boundaries/gb-CHN-ADM1.geojson').catch(() => null)
const nfhsP = getJson('tables/internet-by-adm1.json').then((t) => {
const by = {}
for (const r of t.rows || []) {
if (r.iso3 !== 'IND' || r.measure !== 'ever_used_internet' || r.area !== 'total' || !r.adm1) continue
;(by[r.adm1] ||= {})[r.sex] = r.value // an absent cell stays absent: "no data", never 0
}
adm1Table = by
nfhsAttribution.value = (t.meta && t.meta.sources && t.meta.sources.nfhs5 && t.meta.sources.nfhs5.attribution) || ''
}).catch(() => { adm1Table = null })
try {
const css = engineStylesheetUrl(provider)
if (css && mapEl.value) await ensureEngineCSS(mapEl.value, css)
if (unmounted || !mapEl.value) return
const tk = board.profileConfig.value?.tk || (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
const map = new gl.Map({
container: mapEl.value,
style: resolveNamedStyle('paper', provider).style || 'mapbox://styles/mapbox/light-v11',
accessToken: tk,
locale: engineUiLocale(readerLang(), provider),
center: [60, 20], zoom: 1.4, projection: 'mercator', attributionControl: false,
})
mapRef.value = markRaw(map)
addMapAttribution(map, { boundaries: true })
map.once('load', async () => {
try { localizeBasemapLabels(map, readerLang()) } catch (_) { /* a nicety, never a broken map */ }
const [adm0, ind, chn] = await Promise.all([adm0P, indP, chnP, nfhsP])
if (unmounted) return
// wait for the rows too: the band per country comes from the joined table
if (board.loading.value) await new Promise((res) => { const stop = watch(() => board.loading.value, (l) => { if (!l) { stop(); res() } }) })
addCountries(map, adm0)
addPins(map)
if (ind || chn) {
addProvinces(map, ind, chn)
provinceKeys = [...(ind ? ind.features.map((f) => `IND|${f.properties.shapeName}`) : []),
...(chn ? chn.features.map((f) => `CHN|${f.properties.shapeName}`) : [])]
}
ready.value = true
paintScope()
if (bus.selectionSet.size) moveCamera(boundsOf([...bus.selectionSet].map((s) => board.bySlug.value.get(s)).filter(Boolean)))
else if (board.view.value === 'province') moveCamera({ bounds: [[68, 8], [123, 45]] })
// test hook: what a probe needs to assert DATA drew, not just the basemap (PAINT GATES lesson)
try {
window.__giMap = map
window.__giState = () => ({
provider, pins: map.getSource('gi-pins') ? pinFC().features.length : 0,
renderedPins: map.queryRenderedFeatures({ layers: ['gi-pins'] }).length,
renderedCountries: map.queryRenderedFeatures({ layers: ['gi-adm0-fill'] }).length,
provinces: provinceKeys.length, inScope: board.filtered.value.length,
selected: [...bus.selectionSet], hover: bus.hoverKey.value,
})
} catch (_) {}
})
} catch (e) { err.value = String(e && e.message || e) }
})
onBeforeUnmount(() => {
unmounted = true
if (camTimer) clearTimeout(camTimer)
try { mapRef.value && mapRef.value.remove() } catch (_) {}
})
const approxCount = computed(() => board.all.value.filter((r) => r.shares_point > 0).length)
useShadowStyles(
'.gim{position:relative;width:100%;height:100%;overflow:hidden;background:#e9ece6}' +
'.gim-map{position:absolute;inset:0}' +
'.gim-err{position:absolute;top:45%;left:0;right:0;text-align:center;font-size:13px;color:#6d766e}' +
'.gim-legend{position:absolute;inset-block-end:10px;inset-inline-start:10px;z-index:4;max-width:min(340px,calc(100% - 64px));' +
'background:rgba(255,255,255,.96);color:#3b463d;border:1px solid #d1d4cd;border-radius:10px;padding:8px 10px;' +
'font:12px/1.4 Roboto,-apple-system,"Segoe UI",sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.10)}' +
'.gim-lh{display:flex;align-items:center;gap:8px;font-weight:600;font-size:12px;margin-bottom:4px}' +
'.gim-lh button{margin-inline-start:auto;border:0;background:none;color:#6d766e;cursor:pointer;font:inherit;min-width:24px;min-height:24px}' +
'.gim-keys{display:flex;flex-wrap:wrap;gap:3px 10px;margin-bottom:6px}' +
'.gim-key{display:inline-flex;align-items:center;gap:5px;font-size:11px}' +
'.gim-dot{width:11px;height:11px;border-radius:50%;box-sizing:border-box;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.2);flex:none}' +
'.gim-sw{width:12px;height:10px;border-radius:2px;flex:none;opacity:.8}' +
'.gim-seg{display:inline-flex;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden;margin:2px 0 4px}' +
'.gim-seg button{font:inherit;font-size:11px;padding:3px 8px;min-height:24px;border:0;background:#fff;color:#3b463d;cursor:pointer}' +
'.gim-seg button[aria-pressed="true"]{background:#3b463d;color:#fff}' +
'.gim-note{font-size:10.5px;color:#6d766e;margin-top:3px}' +
'.gim-legend-min{position:absolute;inset-block-end:10px;inset-inline-start:10px;z-index:4;border:1px solid #d1d4cd;background:#fff;color:#3b463d;' +
'border-radius:8px;padding:5px 10px;min-height:28px;font:600 12px Roboto,sans-serif;cursor:pointer}',
'gi-map',
)
</script>
<template>
<div class="gim" data-region="map">
<div ref="mapEl" class="gim-map" :aria-label="T('gospelInternet.map.aria', 'Map of people groups, shaded by how much of each country uses the internet')" role="region"></div>
<div v-if="err" class="gim-err" role="alert">{{ err }}</div>
<MapToolbar v-if="mapRef">
<ZoomInButton :map="mapRef" />
<ZoomOutButton :map="mapRef" />
<ShareButton :embed-path="EMBED_PATH" />
</MapToolbar>
<div v-if="legendOpen" class="gim-legend">
<div class="gim-lh">
<span>{{ T('gospelInternet.legend.pins', 'In their own language') }}</span>
<button type="button" :aria-label="T('gospelInternet.legend.hide', 'Hide the legend')" @click="legendOpen = false">✕</button>
</div>
<div class="gim-keys">
<span v-for="d in ACCESS_DETAILS" :key="d" class="gim-key">
<span class="gim-dot" :style="{ background: DETAIL_FILL[d], borderColor: DETAIL_RIM[d] }"></span>{{ T('gospelInternet.detail.' + d, d) }}
</span>
</div>
<div class="gim-lh"><span>{{ T('gospelInternet.legend.band', 'Country internet use (World Bank)') }}</span></div>
<div class="gim-keys">
<span v-for="b in BANDS" :key="b" class="gim-key"><span class="gim-sw" :style="{ background: BAND_COLOR[b] }"></span>{{ T('gospelInternet.band.' + b, b) }}</span>
</div>
<div class="gim-lh"><span>{{ T('gospelInternet.legend.provinces', 'India and China by province') }}</span></div>
<div class="gim-seg" role="group" :aria-label="T('gospelInternet.legend.provinces', 'India and China by province')">
<button type="button" :aria-pressed="provinceMode === 'zero'" @click="setProvinceMode('zero')">{{ T('gospelInternet.legend.modeZero', 'Nothing in own language') }}</button>
<button type="button" :aria-pressed="provinceMode === 'women'" @click="setProvinceMode('women')">{{ T('gospelInternet.legend.modeWomen', 'Internet: women') }}</button>
<button type="button" :aria-pressed="provinceMode === 'men'" @click="setProvinceMode('men')">{{ T('gospelInternet.legend.modeMen', 'Internet: men') }}</button>
</div>
<div v-if="provinceMode !== 'zero'" class="gim-note">{{ T('gospelInternet.legend.nfhs', 'India only: share of women or men aged 15-49 who have ever used the internet, by state. NFHS-5, 2019-21. China has no verified provincial source.') }} {{ nfhsAttribution }}</div>
<div class="gim-note">{{ T('gospelInternet.notice.pins', 'A dot marks where a people is found in its country, not a town. {n} groups share a point with another group.', { n: approxCount.toLocaleString() }) }}</div>
</div>
<button v-else type="button" class="gim-legend-min" @click="legendOpen = true">{{ T('gospelInternet.legend.show', 'Legend') }}</button>
</div>
</template>

View file

@ -0,0 +1,216 @@
/**
* model.js — the Gospel x Internet board's DATA RULES, with no Vue and no DOM, so every rule here is
* testable in node (scripts/check-gospel-internet-model.mjs).
*
* ★ CODES CROSS, LABELS DO NOT. Every facet value on the bus is a stable code (access_level own|bridge
* |zero, access_detail read_watch|hear|read|stories|none, band yes|partial|no|nodata, ISO 3166 alpha-3,
* ISO 639-3, a province's shapeName). The components translate for display; nothing ever filters on a
* translated string (SPEC-DASHBOARD-KIT section 5.3: a label link matches nothing in Arabic).
*
* ★ ONE PREDICATE. Nothing in this plugin compares a row with filterSet itself: every "does this row
* survive" question goes through the kit bus's rowMatchesFilters(row, ACC), including the facet
* counts (which call it with the facet's OWN key left out of ACC, so a facet counts under every OTHER
* facet). The counts a reader acts on before clicking are, by construction, the rows the map, the grid,
* the charts and the CSV then show.
*/
// ── the access ladder (the Driver's three words, then the sub-ladder) ───────────────────────────────
export const ACCESS_LEVELS = ['own', 'bridge', 'zero']
export const ACCESS_DETAILS = ['read_watch', 'read', 'hear', 'stories', 'none']
export const BANDS = ['yes', 'partial', 'no', 'nodata']
export const RESOURCE_KINDS = ['scripture', 'film', 'audio', 'video', 'stories']
// Pins say state by FORM, as Gospel Resources' do (resources-map.vue PIN_COLOR/PIN_RIM): "nothing in
// own language" is a HOLE — white core, oxblood rim — and the eye goes to the holes first. The sage
// ramp is the dx-life accent. (Phase 2: move this ladder into library/colors/ with Gospel Resources
// as its second consumer, rather than two plugins keeping two copies.)
export const DETAIL_FILL = { read_watch: '#3b463d', read: '#5f7a62', hear: '#92b195', stories: '#c9d6c6', none: '#ffffff' }
export const DETAIL_RIM = { read_watch: '#ffffff', read: '#ffffff', hear: '#ffffff', stories: '#ffffff', none: '#7d3b30' }
// Cities with Internet's band colours (connectivity-map.vue BANDS), so one band reads the same on
// both maps. (Phase 2: library/colors/internet-band.js, one rule for both.)
export const BAND_COLOR = { yes: '#10b981', partial: '#e8c15a', no: '#b42318', nodata: '#9aa2ad' }
export const LEVEL_COLOR = { own: '#3b463d', bridge: '#7d3b30', zero: '#1f1f1f' }
// ── facets: key -> accessor. THE accessor; the rail, the map, the grid and the CSV all read it ──────
export const FACET_ACC = {
access_level: (r) => r.access_level,
access_detail: (r) => r.access_detail,
band: (r) => r.internet_band,
region: (r) => r.region || '',
country: (r) => r.iso3,
province: (r) => r.province || '',
language: (r) => r.iso639_3,
religion: (r) => r.religion || '',
way_in: (r) => r.way_in || '',
}
// The facet hierarchy, declared ONCE and in the kit's shape (facetRail[].drill), so the bus's
// configureHierarchy() derives the cascade: clearing a country clears its province, clearing a
// region clears its country (and so its province). Clearing never leaves an orphan.
export const FACET_RAIL = [
{ id: 'access', drill: ['access_level', 'access_detail'] },
{ id: 'band', drill: ['band'] },
{ id: 'place', drill: ['region', 'country', 'province'] },
{ id: 'language', drill: ['language'] },
{ id: 'religion', drill: ['religion'] },
{ id: 'way_in', drill: ['way_in'] },
]
// ── URL state (SPEC-GOSPEL-INTERNET-DASHBOARD section 3; kit D2's query form, glue until it lands) ──
// query name -> facet key. `|` separates several values (a comma occurs inside real values).
export const URL_KEYS = {
access: 'access_level', detail: 'access_detail', band: 'band', region: 'region',
country: 'country', province: 'province', language: 'language', religion: 'religion', wayin: 'way_in',
}
export const VIEWS = ['board', 'zero', 'province']
/** Parse `?view=&access=…&group=` into { view, filters, group }. Unknown views fall back to board. */
export function parseQuery(search) {
const q = new URLSearchParams(search || '')
const filters = {}
for (const [name, key] of Object.entries(URL_KEYS)) {
const raw = q.get(name)
if (!raw) continue
const vals = raw.split('|').map((s) => s.trim()).filter(Boolean)
if (vals.length) filters[key] = vals.length === 1 ? vals[0] : vals
}
const view = VIEWS.includes(q.get('view')) ? q.get('view') : 'board'
return { view, filters, group: q.get('group') || '' }
}
/** The inverse: filterSet + view + group -> a query string (no leading '?'), stable key order. */
export function buildQuery({ view, filterSet, group, keep } = {}) {
const q = new URLSearchParams(keep || '')
for (const name of [...Object.keys(URL_KEYS), 'view', 'group']) q.delete(name)
if (view && view !== 'board') q.set('view', view)
for (const [name, key] of Object.entries(URL_KEYS)) {
const v = filterSet?.[key]
if (v === undefined || v === null || v === '') continue
q.set(name, Array.isArray(v) ? v.join('|') : String(v))
}
if (group) q.set('group', group)
return q.toString()
}
// ── the views are PRESETS over the one bus, never separate state ────────────────────────────────────
export function viewPreset(view) {
if (view === 'zero') return { filters: { access_detail: 'none' }, sort: { key: 'population', dir: -1 } }
if (view === 'province') return { filters: { country: ['IND', 'CHN'] }, sort: { key: 'population', dir: -1 } }
return { filters: {}, sort: { key: 'population', dir: -1 } }
}
// ── rows ─────────────────────────────────────────────────────────────────────────────────────────────
/** Fill the fields the builder omitted as empty (meta.columns is the schema), so every accessor reads
* a defined value. Absence becomes '' / null — never 0, which would read as a measurement. */
export function normalizeRows(doc) {
const cols = doc?.meta?.columns || []
const NUMERIC = new Set(['population', 'internet_pct', 'mobile_per_100', 'top_platform_pct', 'bridge_candidate_pct_of_country',
'state_internet_women', 'state_internet_men', 'people_praying', 'lat', 'lng'])
const BOOL = new Set(['bridge_has_scripture', 'bridge_has_film'])
return (doc?.rows || []).map((r) => {
const o = {}
for (const c of cols) o[c] = r[c] !== undefined ? r[c] : BOOL.has(c) ? false : NUMERIC.has(c) ? null : c === 'shares_point' ? 0 : ''
return o
})
}
/** Rows surviving every active facet — the kit bus's predicate, never a private copy. */
export function filterRows(rows, bus, ACC = FACET_ACC, term = '') {
const t = String(term || '').trim().toLowerCase()
return rows.filter((r) => bus.rowMatchesFilters(r, ACC) && (!t || matchesTerm(r, t)))
}
export function matchesTerm(r, t) {
return (r.people_group || '').toLowerCase().includes(t) || (r.country || '').toLowerCase().includes(t)
|| (r.language || '').toLowerCase().includes(t) || r.iso639_3 === t || (r.iso3 || '').toLowerCase() === t
|| (r.province || '').toLowerCase().includes(t)
}
/**
* Counts for one facet UNDER THE OTHER FACETS (the cascade a reader expects: picking a band shows how
* many groups of each access level remain). Uses the bus predicate with this facet's own key left out.
* Returns [{ value, groups, people }] sorted by groups desc (or by `order` when given).
*/
export function facetCounts(rows, bus, key, { order, ACC = FACET_ACC, term = '' } = {}) {
const others = {}
for (const k of Object.keys(ACC)) if (k !== key) others[k] = ACC[k]
const get = ACC[key]
const t = String(term || '').trim().toLowerCase()
const m = new Map()
for (const r of rows) {
if (!bus.rowMatchesFilters(r, others)) continue
if (t && !matchesTerm(r, t)) continue
const v = get(r)
if (v === '' || v === null || v === undefined) continue
const e = m.get(v) || { value: v, groups: 0, people: 0 }
e.groups++
e.people += Number(r.population) || 0
m.set(v, e)
}
const out = [...m.values()]
if (order) {
for (const v of order) if (!m.has(v)) out.push({ value: v, groups: 0, people: 0 })
return out.sort((a, b) => order.indexOf(a.value) - order.indexOf(b.value))
}
return out.sort((a, b) => b.groups - a.groups || String(a.value).localeCompare(String(b.value)))
}
/** Stacked series: access_detail within each band (chart 1). */
export function accessByBand(rows, measure = 'groups') {
const out = {}
for (const b of BANDS) out[b] = Object.fromEntries(ACCESS_DETAILS.map((d) => [d, 0]))
for (const r of rows) {
const cell = out[r.internet_band]
if (!cell || !(r.access_detail in cell)) continue
cell[r.access_detail] += measure === 'people' ? (Number(r.population) || 0) : 1
}
return out
}
export function sortRows(rows, { key, dir = 1 } = {}) {
if (!key) return rows
const num = (v) => (v === null || v === '' || v === undefined ? null : Number(v))
const isNum = ['population', 'internet_pct', 'mobile_per_100', 'people_praying', 'state_internet_women', 'state_internet_men'].includes(key)
return [...rows].sort((a, b) => {
const x = a[key], y = b[key]
if (isNum) {
const p = num(x), q = num(y)
if (p === null && q === null) return 0
if (p === null) return 1 // no data sorts last in BOTH directions: never read as "lowest"
if (q === null) return -1
return (p - q) * dir
}
return String(x ?? '').localeCompare(String(y ?? '')) * dir
})
}
// ── CSV (glue to the kit's useCsvExport contract until landing 6 reaches main) ──────────────────────
/** RFC-4180 quoting + CSV-injection neutralising, header on line 1, provenance LAST (the kit's rule). */
export function buildCsv(rows, columns, { provenance = '' } = {}) {
const esc = (v) => {
let s = v === null || v === undefined ? '' : String(v)
if (/^[=+\-@]/.test(s)) s = "'" + s
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
return s
}
const lines = [columns.map((c) => esc(c.header)).join(',')]
for (const r of rows) lines.push(columns.map((c) => esc(c.csv ? c.csv(r) : r[c.key])).join(','))
if (provenance) lines.push(esc(provenance))
return lines.join('\r\n')
}
/** Export scope, in the kit's order: selection > filter result > all. */
export function exportScope(rows, filtered, selectionSet) {
if (selectionSet && selectionSet.size) {
const pick = rows.filter((r) => selectionSet.has(r.slug))
return { scope: 'selection', rows: pick }
}
return { scope: filtered.length === rows.length ? 'all' : 'filter', rows: filtered }
}
/** A safe outbound URL: http(s) only (a javascript: or data: link in a table cell is never followed).
* Not library/utils/html.js safeUrl: that one HTML-ESCAPES its result for string-built popups, and a
* Vue :href binding escapes again, so '&' would reach the browser as '&amp;amp;'. Every link in this
* table already passed Gospel Resources' doctrine screen at build time; this is the protocol guard. */
export function safeHref(u) {
const s = String(u || '').trim()
return /^https?:\/\//i.test(s) ? s : ''
}

View file

@ -0,0 +1,143 @@
/**
* useBoard.js — the board's ONE shared state, provided to every panel (SPEC-DASHBOARD-KIT section 5.1,
* level 1: panels never talk to each other; they read and write one injected bus).
*
* Built FROM the kit: the bus is library/composables/useSelectionBus.js, unchanged. What is here is the
* thin glue the kit does not have yet — data loading, view presets and query-param URL state (kit D2,
* decided, not built) — written to the contract so each piece can be swapped for the kit's when it lands.
*
* Provides 'giBoard': { bus, all, filtered, meta, loading, error, view, sort, openSlug, listId, ... }.
*/
import { ref, shallowRef, computed, watch, inject, provide, markRaw } from 'vue'
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
import { resolveAssetsBase, resolveShareUrl } from '@map/utils/embedBase.js'
import { applyPeopleGroupList, resolvePeopleGroupList, LIST_DEFAULT } from '@map/lists/peopleGroupList.js'
import * as M from './model.js'
export const EMBED_PATH = 'gospel-internet/board/index.html'
export function useBoard() {
const profileConfig = inject('profileConfig', ref(null))
const cfg = () => profileConfig.value || {}
const bus = createSelectionBus()
bus.configureHierarchy({ facetRail: M.FACET_RAIL })
const all = shallowRef([])
const bySlug = shallowRef(new Map())
const meta = shallowRef({})
const loading = ref(true)
const error = ref('')
const listId = computed(() => resolvePeopleGroupList(profileConfig.value))
const listIsDefault = computed(() => listId.value === LIST_DEFAULT)
const view = ref('board')
const sort = ref({ key: 'population', dir: -1 })
const openSlug = ref('') // the group in the detail drawer ('' = closed)
// Every panel's row set. The filterSet reads inside rowMatchesFilters are reactive, so this recomputes
// on any facet change; searchTerm is read explicitly for the same reason.
const filtered = computed(() => {
const term = bus.searchTerm.value
for (const k of Object.keys(bus.filterSet)) void bus.filterSet[k]
return M.filterRows(all.value, bus, M.FACET_ACC, term)
})
const sorted = computed(() => M.sortRows(filtered.value, sort.value))
const openRow = computed(() => (openSlug.value && bySlug.value.get(openSlug.value)) || null)
async function load() {
loading.value = true
error.value = ''
try {
const url = `${resolveAssetsBase(cfg())}/tables/gospel-internet.json`
const res = await fetch(url)
if (!res.ok) throw new Error(`gospel-internet.json HTTP ${res.status}`)
const doc = await res.json()
// The universal `list` prop: the rows of the page's people-group list, and none if it cannot be
// loaded (applyPeopleGroupList fails CLOSED — never DOXA's whole list under another list's name).
const rows = await applyPeopleGroupList(M.normalizeRows(doc), profileConfig.value)
meta.value = markRaw(doc.meta || {})
all.value = markRaw(rows)
bySlug.value = markRaw(new Map(rows.map((r) => [r.slug, r])))
} catch (e) {
error.value = String(e?.message || e)
} finally {
loading.value = false
}
}
// ── views are PRESETS over the one bus ────────────────────────────────────────────────────────────
function setView(v, { keepFilters = false } = {}) {
const next = M.VIEWS.includes(v) ? v : 'board'
view.value = next
const preset = M.viewPreset(next)
if (!keepFilters) {
bus.clearFilters()
for (const [k, val] of Object.entries(preset.filters)) bus.setFilter(k, val)
}
sort.value = { ...preset.sort }
}
function openGroup(slug, { select = true } = {}) {
openSlug.value = slug || ''
// Opening a group is an explicit pick, so it SELECTS (and the selection camera moves once). Hover
// never does this — see useSelectionBus D3.
if (select && slug) bus.setSelection([slug])
}
function closeGroup() { openSlug.value = '' }
// ── URL state: ?view=&access=&band=&country=&province=&language=&group= (read always) ─────────────
// WRITTEN only when the host opts in (profile-config urlState:'query', set by our own leaf pages).
// A partner embedding the element on their page did not ask us to rewrite their address bar.
const writeUrl = computed(() => cfg().urlState === 'query')
function readUrl() {
if (typeof location === 'undefined') return
const q = M.parseQuery(location.search)
const hostView = cfg().view
setView(q.view !== 'board' ? q.view : (M.VIEWS.includes(hostView) ? hostView : 'board'))
// Explicit facets in the URL (or the host's profile-config `filters`) narrow the preset further.
const hostFilters = cfg().filters && typeof cfg().filters === 'object' ? cfg().filters : {}
for (const [k, v] of Object.entries({ ...hostFilters, ...q.filters })) if (k in M.FACET_ACC) bus.setFilter(k, v)
const group = q.group || cfg().group || ''
if (group) pendingGroup = group
}
let pendingGroup = ''
watch(all, () => {
if (pendingGroup && bySlug.value.has(pendingGroup)) openGroup(pendingGroup)
pendingGroup = ''
})
function syncUrl() {
if (!writeUrl.value || typeof history === 'undefined') return
const qs = M.buildQuery({ view: view.value, filterSet: bus.filterSet, group: openSlug.value, keep: location.search })
const next = location.pathname + (qs ? '?' + qs : '') + location.hash
if (next !== location.pathname + location.search + location.hash) {
try { history.replaceState(history.state, '', next) } catch (_) { /* sandboxed */ }
}
}
watch([view, openSlug, () => JSON.stringify(bus.filterSet)], syncUrl)
/** A link to one group, for "Copy link to this group" — the board's own page, ?group=<slug>. */
function groupLink(slug) {
const base = resolveShareUrl({ embedPath: EMBED_PATH, profileConfig: cfg() })
try {
const u = new URL(base, typeof location !== 'undefined' ? location.href : 'https://example.invalid/')
u.searchParams.set('group', slug)
if (!listIsDefault.value) u.searchParams.set('list', listId.value)
return u.href
} catch (_) { return base + '?group=' + encodeURIComponent(slug) }
}
const board = {
bus, all, bySlug, filtered, sorted, meta, loading, error, listId, listIsDefault,
view, sort, openSlug, openRow, profileConfig,
load, setView, openGroup, closeGroup, readUrl, groupLink,
}
provide('giBoard', board)
return board
}
export function useBoardState() {
const b = inject('giBoard', null)
if (!b) throw new Error('[gospel-internet] a panel was mounted outside the board (no giBoard provided)')
return b
}

View file

@ -0,0 +1,19 @@
/**
* useGiT.js — the house translation alias, T(key, en, params): the catalogue's string in the reader's
* language, else the English written at the call site.
*
* The English argument is not a second catalogue. vue-i18n's own fallback is the ENGLISH CATALOGUE
* ENTRY, so a key missing from every locale would render as the raw key ("gospelInternet.foo"); the
* call-site English is what keeps a missing key readable while check:i18n-keys-exist reports it.
* Keys are always FULL dotted paths (gospelInternet.*, buttons.*, …) so that gate can read them.
*/
import { useOptionalT, fillParams } from '@map/composables/useOptionalT.js'
export function useGiT() {
const t = useOptionalT()
return (key, en, params) => {
let v
try { v = t(key, params) } catch (_) { v = key }
return !v || v === key ? fillParams(en ?? key, params) : v
}
}

File diff suppressed because one or more lines are too long