Carry the map selection into the resource table

The table listed every link but had no idea what you had just tapped, so answering
"what exists for THIS group" meant reading a language name off the map and typing it
into a filter box. The map and the table are separate PAGES, so the selection travels
as a URL: ?tab=links&iso=<code> from a people group, &country=<ISO3> from a country.

FILTERED IN THE PROFILE, NOT IN THE SHARED COMPONENT. DataTable has other consumers
and none of them need a filter prop; teaching it one would put a change with repo-wide
blast radius behind a feature only this bundle uses. The rows are filtered before they
are handed over, which is invisible to every other caller.

COUNTRY FILTERING WITHOUT A SECOND FETCH. Rows carry country_codes, the ISO3 set the
language is spoken in, so a country filter is a membership test on data already loaded
rather than pulling the 679KB pin table just to resolve which languages a country has.
+23KB for the column, against 679KB avoided.

THE EMPTY CASE SAYS WHY. A selection with no match renders "nothing found for this
selection in the pools checked, which is not proof that nothing exists" rather than an
empty grid, which would read as a broken filter or, worse, as proof of absence.
Switching to a tab that has no language key says so too instead of showing zero rows.

Verified end to end on the served tree: clicked a real pin, followed the link THE SHEET
RENDERED rather than one built in the test, and landed on 2 rows both carrying cmn with
"Showing 2 of 581 resources for Chinese, Mandarin". Country path checked the same way —
Nepal 51, Chad 22, unfiltered 581 with no banner, and an unmatched code showing the
honest empty state.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 03:35:35 +00:00
parent 410f101846
commit dfb4f755be
13 changed files with 100 additions and 31 deletions

File diff suppressed because one or more lines are too long

View file

@ -45,7 +45,24 @@ const TABS = [
{ id: 'languages', label: 'Languages by Country', file: 'languages-by-country' },
{ id: 'growth', label: 'Access vs Population Growth', file: 'gospel-access-vs-growth' },
]
const active = ref('links')
// DEEP-LINK FILTER. The map and this table are separate PAGES, so "click a pin and the
// table follows" is a URL, not shared state. The map links here with ?tab=links&iso=<code>
// or &country=<ISO3>, and the rows are filtered HERE rather than by teaching the shared
// DataTable a new prop — that component has other consumers and this needs none of them.
function qp() {
try { return new URLSearchParams(window.location.search) } catch { return new URLSearchParams() }
}
const params = qp()
const fIso = ref((params.get('iso') || '').trim().toLowerCase())
const fCountry = ref((params.get('country') || '').trim().toUpperCase())
const fLabel = ref((params.get('label') || '').trim())
const hasFilter = computed(() => !!(fIso.value || fCountry.value))
function clearFilter() {
fIso.value = ''; fCountry.value = ''; fLabel.value = ''
try { window.history.replaceState({}, '', window.location.pathname) } catch (_) {}
}
const active = ref(TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'links')
const current = computed(() => TABS.find((t) => t.id === active.value) || TABS[0])
// Fetched at runtime — see the note in cities-internet/data-tables.vue. These two
@ -64,7 +81,22 @@ async function ensure(file) {
finally { loading.value = false }
}
watch(current, (t) => t && ensure(t.file), { immediate: true })
const table = computed(() => cache.value[current.value?.file] || null)
const rawTable = computed(() => cache.value[current.value?.file] || null)
// Only the links table carries iso639_3/country_codes, so a filter is a NO-OP elsewhere
// rather than an empty table — switching tabs under an active filter must not look broken.
const table = computed(() => {
const t = rawTable.value
if (!t || !hasFilter.value || current.value?.id !== 'links') return t
const rows = (t.rows || []).filter((r) => {
if (fIso.value && String(r.iso639_3 || '').toLowerCase() !== fIso.value) return false
if (fCountry.value && !String(r.country_codes || '').toUpperCase().split(' ').includes(fCountry.value)) return false
return true
})
return { ...t, rows }
})
const filterCount = computed(() => (table.value?.rows || []).length)
const totalCount = computed(() => (rawTable.value?.rows || []).length)
// Computed from the growth table ONCE it has loaded — the hero is hidden until
// then rather than rendering zeros, which would read as "no gap" (the opposite of
@ -92,6 +124,9 @@ useShadowStyles(
'background:rgba(220,38,38,.07);border:1px solid rgba(220,38,38,.18)}' +
'.is-dark .gt-hero{background:rgba(220,38,38,.12);border-color:rgba(248,113,113,.25)}' +
'.gt-hero b{font-weight:800;font-variant-numeric:tabular-nums}' +
'.gt-filter{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-size:13px}' +
'.gt-clear{border:1px solid #d1d4cd;background:#fff;color:#3b463d;font:600 12px inherit;padding:5px 11px;border-radius:7px;cursor:pointer;min-height:32px}' +
'.gt-clear:hover{background:#f3f3f1}' +
'.gt-tabs{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}' +
'.gt-tab{font:600 12px inherit;padding:7px 14px;border-radius:999px;cursor:pointer;' +
'border:1px solid rgba(0,0,0,.14);background:transparent;color:inherit}' +
@ -126,11 +161,21 @@ useShadowStyles(
</button>
</div>
<div v-if="hasFilter" class="gt-filter">
<span v-if="current.id === 'links'">
Showing <b>{{ fmt(filterCount) }}</b> of {{ fmt(totalCount) }} resources
for <b>{{ fLabel || fIso || fCountry }}</b>
<template v-if="!filterCount"> — nothing found for this selection in the pools checked, which is not proof that nothing exists.</template>
</span>
<span v-else>Filtered to <b>{{ fLabel || fIso || fCountry }}</b> — this tab is not filtered; only “Every Resource + Link” carries a language key.</span>
<button type="button" class="gt-clear" @click="clearFilter">Show all</button>
</div>
<div v-if="loadErr" class="gt-err">{{ loadErr }}</div>
<div v-else-if="!table" class="gt-load">Loading {{ current.label }}…</div>
<DataTable
v-else
:key="current.id"
:key="current.id + ':' + fIso + ':' + fCountry"
:table="table"
:assets-base="assetsBase"
:is-dark="isDark"

View file

@ -432,6 +432,13 @@ function setMode(m) {
const fmt = (n) => (Number(n) || 0).toLocaleString()
// The table lives on a SIBLING page, so "see all of these" is a URL. Built here rather
// than inline in the template so the group link and the country link cannot drift apart.
const tableLink = (q) => {
const p = new URLSearchParams({ tab: 'links', ...q })
return `../data-tables/index.html?${p.toString()}`
}
useShadowStyles(
'.gm{position:absolute;inset:0;overflow:hidden}' +
'.gm-map{position:absolute;inset:0}' +
@ -492,6 +499,9 @@ useShadowStyles(
'.is-dark .gm-mode{border-color:rgba(255,255,255,.22)}' +
'.is-dark .gm-mode.on{background:#10b981;border-color:#10b981;color:#04241a}' +
// RESOURCE LIST inside the sheet.
'.gm-tablink{display:block;margin:10px 0 2px;padding:9px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-weight:600;font-size:12.5px;text-decoration:none;min-height:36px;box-sizing:border-box}' +
'.gm-tablink:hover{background:#e9ebe6}' +
'.is-dark .gm-tablink{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
'.gm-sec{margin-top:12px}' +
'.gm-sec-h{font-weight:800;font-size:12px;letter-spacing:.02em;margin-bottom:6px}' +
'.gm-load{padding:10px 0;font-size:11.5px;opacity:.65}' +
@ -644,6 +654,11 @@ useShadowStyles(
</div>
</div>
<a class="gm-tablink" :href="tableLink({ iso: selectedGroup.iso639_3 || '', label: selectedGroup.language || '' })"
v-if="selectedGroup.iso639_3">
Every resource in {{ selectedGroup.language }} →
</a>
<div class="gm-sec" v-if="sameLanguage.length">
<div class="gm-sec-h">Also speak {{ selectedGroup.language }} ({{ sameLanguage.length }})</div>
<button v-for="g in sameLanguage.slice(0, 12)" :key="g.people_group + g.countries"
@ -680,6 +695,11 @@ useShadowStyles(
<div class="gm-stat"><span>Growing &amp; unreached</span><b>{{ fmt(selected.growing_unreached) }}</b></div>
<div class="gm-stat"><span>Est. deaths/day</span><b>{{ fmt(Math.round(selected.deaths_day)) }}</b></div>
<a class="gm-tablink" :href="tableLink({ country: selected.iso3 || '', label: selected.country || '' })"
v-if="selected.iso3">
Every resource used in {{ selected.country }} →
</a>
<div v-if="pgLoading" class="gm-load">Loading resources…</div>
<div v-else-if="pgErr" class="gm-err" style="position:static;text-align:left">{{ pgErr }}</div>

View file

@ -64,6 +64,10 @@ for (const [code, c] of Object.entries(codes)) {
speakers: r.speakers,
countries: r.countries.size,
example_group: r.sample[0] || '',
// ISO3 country codes this language is spoken in, space-joined. Carried so the table
// can be filtered to a COUNTRY from a map click without loading the 679KB pin table
// just to resolve which languages a country contains.
country_codes: [...r.countries].sort().join(' '),
resource_url: url,
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -556,7 +556,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "doxa-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt",
"integrity": "sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci",
"elements": [
"gospel-resources-map"
],
@ -572,7 +572,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -586,7 +586,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -600,7 +600,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -614,7 +614,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-fNkv0DCvTk9lRwT+YZUq+4kSz8aV3//MY9kf1iwbAjYQll2DtABUNutRmgzkBOrt\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-ghGJCWgzNE6CZFo6Txri3COKSOwM+LmqEj4p6tBo9B/eUoczfRH9p9Ogo+mT+4ci\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]