From 0d8d1b33022cdda08318d59f39111c22c6cbb5ed Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 06:53:22 +0000 Subject: [PATCH] Gospel map: provinces for India and China, fetched only when someone looks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 rendering. Opening India or China now draws its provinces, coloured by the same ladder as the country beneath them, and tapping one gives its groups, its people, how many have nothing, the largest of those and the way in. It arrives on intent rather than on load. India's ADM1 geometry is 1,628 KB — nearly the size of the whole world ADM0 file — on a leaf that already loads about 4 MB and is meant to be embedded on thousands of partner sites. Loading it for the 173 countries nobody is looking at would tax every reader for the benefit of two. Opening one of the two IS the signal; China is 54 KB, and both are cached after the first open. The counts come from the measured table, never recomputed in the leaf. The point-in-polygon belongs in the build, and a second implementation here is how two surfaces come to disagree. Provinces repaint when the mode changes, because a country saying one thing while the province on top of it says another is worse than having no provinces at all. The panel repeats that placement is approximate, and that the way in was chosen for the COUNTRY — which is exactly the limitation going below the border exposed. Also fixes the gate I added for this, twice. The province fetch interpolates its filename, so the parse captured the literal prefix "boundaries/" — a real directory of 4,096 bytes that always exists, proving nothing. The first fix expanded that prefix against the filesystem, which was worse: unstaging a real geojson simply shortened the list and the gate stayed green on nine assets instead of ten, exit 0. The expectation now comes from the filenames the leaf names, so a file the map asks for and the tree lacks fails. Poison-tested both ways. --- internal/check-gospel-tables-staged.mjs | 31 ++++ .../gospel-resources/resources-map.vue | 138 +++++++++++++++++- public/dx-maps/doxa-maps-spa/manifest.json | 12 +- .../gospel-resources/charts/index.html | 2 +- .../gospel-resources/data-tables/index.html | 2 +- .../gospel-dashboard/index.html | 2 +- .../gospel-resources/gospel-resources.js | 42 +++--- .../resources-list/index.html | 2 +- .../gospel-resources/resources-map/index.html | 2 +- public/dx-maps/manifest.json | 12 +- 10 files changed, 205 insertions(+), 40 deletions(-) diff --git a/internal/check-gospel-tables-staged.mjs b/internal/check-gospel-tables-staged.mjs index c52079f5f..cfc4a5513 100644 --- a/internal/check-gospel-tables-staged.mjs +++ b/internal/check-gospel-tables-staged.mjs @@ -40,8 +40,39 @@ if (!wanted.length) { process.exit(2) } +/** + * ⚠ A DYNAMIC FETCH RESOLVES TO A DIRECTORY, AND A DIRECTORY ALWAYS EXISTS. + * The province layer fetches `${assetsBase}/boundaries/${PROVINCE_FILE[iso3]}`, so the parse + * above captures the literal prefix "boundaries/" and stops at the interpolation. That path is + * a real directory of 4,096 bytes, so it passed — proving nothing about the two files the map + * actually asks for. A check that cannot fail is the thing this repo keeps rediscovering. + * So a directory is never accepted as an asset, and any literal asset FILENAME mentioned in the + * leaf is checked on its own. That covers the interpolated case without trying to evaluate it. + */ +const dirLike = wanted.filter((r) => r.endsWith('/')) +const named = [...new Set( + [...src.matchAll(/['"`]([A-Za-z0-9_\-]+\.(?:geojson|json))['"`]/g)].map((m) => m[1]), +)] +/* ⚠ AND THE EXPANSION MUST NOT CONSULT THE FILESYSTEM TO DECIDE WHAT TO EXPECT. + * The first version only added a name if the file already existed, so deleting one simply + * removed it from the list and the gate went green on nine assets instead of ten. Proven by + * unstaging gb-CHN-ADM1.geojson: PASS, exit 0. The expectation has to come from the SOURCE — + * the filenames the leaf names — so that a file the leaf asks for and the tree lacks is a + * failure rather than a shorter list. */ +for (const prefix of dirLike) { + const ext = /boundaries\//.test(prefix) ? '.geojson' : '.json' + for (const file of named.filter((f) => f.endsWith(ext))) wanted.push(prefix + file) +} + const missing = [] for (const rel of wanted) { + if (rel.endsWith('/')) { + /* Counted, not silently skipped: if the expansion above found nothing for this prefix the + reader must know the interpolated fetch went unverified. */ + const found = wanted.filter((w) => w !== rel && w.startsWith(rel)) + if (!found.length) missing.push(`${rel} — a dynamic fetch this check could not resolve to a file`) + continue + } const p = resolve(POOL, rel) if (!existsSync(p)) { missing.push(`${rel} — not staged`); continue } const bytes = statSync(p).size diff --git a/plugin-designs/gospel-resources/resources-map.vue b/plugin-designs/gospel-resources/resources-map.vue index 1f06e05d3..e22b5e77d 100644 --- a/plugin-designs/gospel-resources/resources-map.vue +++ b/plugin-designs/gospel-resources/resources-map.vue @@ -964,12 +964,16 @@ function openCountry(iso) { if (!iso || !byIso.value[iso]) return selected.value = byIso.value[iso] selectedGroup.value = null + selectedProvince.value = null shown.value = PAGE ensurePeopleGroups() + /* Opening India or China IS the intent signal. 1.6 MB of Indian geometry is not something to + spend on the 173 countries nobody is looking at. */ + ensureProvinces(iso) } function openGroup(g) { selectedGroup.value = g } -function closeSheet() { selected.value = null; selectedGroup.value = null } +function closeSheet() { selected.value = null; selectedGroup.value = null; selectedProvince.value = null } async function load() { const [data, adm0, pinData, doxaData] = await Promise.all([ @@ -1087,6 +1091,108 @@ function paint(map, adm0) { map.on('mouseleave', 'gr-fill', () => { map.getCanvas().style.cursor = '' }) } +/** + * ═══ PROVINCES, FETCHED ONLY WHEN SOMEONE LOOKS ══════════════════════════════════════════ + * + * India and China hold 47.9% of the roster (855 + 154 of 2,107) and a national fill cannot + * answer "where would a team go" for half the map. But India's ADM1 geometry is 1,628 KB — + * nearly the size of the whole world ADM0 file — on a leaf that already loads about 4 MB and + * is meant to be embedded on thousands of partner sites. Loading it for the 173 countries + * nobody is looking at would be a tax on every reader for the benefit of two. + * + * So it arrives on intent: opening India or China fetches that country's provinces once, and + * nothing else changes for anyone else. China is 54 KB, India 1,628 KB, and both are cached + * after the first open. + * + * COLOURED BY THE SAME LADDER as the country fill, because a reader who has just learned what + * the clay ramp means must not have to learn a second scale one zoom later. + */ +const provinceState = ref({}) // iso3 -> 'loading' | 'ready' | 'failed' +const provinceGeo = ref({}) // iso3 -> the loaded FeatureCollection, kept for recolouring +/* A province is coloured by the SAME rule as a country, mode included — a reader who has just + learned the clay ramp must not meet a second scale one zoom later. featureColor cannot be + reused directly because it looks a country up by iso3; this is the same two lines against a + province's own counts. */ +const colourProvinces = (geo) => { + for (const f of geo.features) { + const n = mode.value === 'gap' ? f.properties.zero : f.properties.groups - f.properties.zero + const b = f.properties.groups > 0 ? bandFor(mode.value === 'gap' ? BANDS : COVER_BANDS, n) : null + f.properties.pcolor = b ? b.color : NO_DOXA_FILL + } +} +/* Mode changes after a province layer exists would otherwise leave it painted in the old + scale — the country beneath it would say one thing and the province another. */ +const repaintProvinces = () => { + const map = mapRef.value + if (!map) return + for (const [iso3, geo] of Object.entries(provinceGeo.value)) { + colourProvinces(geo) + const src = map.getSource(`gr-prov-${iso3}`) + if (src) src.setData(geo) + } +} +const provinceRows = ref(null) // the measured table: province -> counts +const selectedProvince = ref(null) + +async function ensureProvinceTable() { + if (provinceRows.value) return provinceRows.value + const r = await fetch(`${assetsBase.value}/tables/gospel-by-province.json`) + provinceRows.value = await r.json() + return provinceRows.value +} + +const PROVINCE_FILE = { IND: 'gb-IND-ADM1.geojson', CHN: 'gb-CHN-ADM1.geojson' } + +async function ensureProvinces(iso3) { + const map = mapRef.value + if (!map || !PROVINCE_FILE[iso3] || provinceState.value[iso3]) return + provinceState.value = { ...provinceState.value, [iso3]: 'loading' } + try { + const [geo, table] = await Promise.all([ + fetch(`${assetsBase.value}/boundaries/${PROVINCE_FILE[iso3]}`).then((r) => r.json()), + ensureProvinceTable(), + ]) + /* The counts come from the measured table, never recomputed here — the point-in-polygon + that produced them belongs in the build, and a second implementation in the leaf is how + two surfaces come to disagree. */ + const by = {} + for (const row of (table.rows || [])) if (row.iso3 === iso3) by[row.province] = row + for (const f of geo.features) { + const row = by[f.properties.shapeName] + f.properties.zero = row ? row.zero_access_groups : 0 + f.properties.groups = row ? row.groups : 0 + } + provinceGeo.value = { ...provinceGeo.value, [iso3]: geo } + colourProvinces(geo) + const src = `gr-prov-${iso3}` + if (!map.getSource(src)) { + map.addSource(src, { type: 'geojson', data: geo }) + map.addLayer({ + id: `${src}-fill`, type: 'fill', source: src, + paint: { 'fill-color': ['get', 'pcolor'], 'fill-opacity': ['case', ['>', ['get', 'groups'], 0], 0.82, 0] }, + }, 'gr-line') + map.addLayer({ + id: `${src}-line`, type: 'line', source: src, + paint: { 'line-color': 'rgba(255,255,255,0.75)', 'line-width': 0.7 }, + }, 'gr-pins-halo') + map.on('click', `${src}-fill`, (e) => openProvince(iso3, e.features?.[0]?.properties?.shapeName)) + map.on('mouseenter', `${src}-fill`, () => { map.getCanvas().style.cursor = 'pointer' }) + map.on('mouseleave', `${src}-fill`, () => { map.getCanvas().style.cursor = '' }) + } + provinceState.value = { ...provinceState.value, [iso3]: 'ready' } + } catch (_) { + /* A province view is an enrichment; losing it must never take the country view with it. */ + provinceState.value = { ...provinceState.value, [iso3]: 'failed' } + } +} + +function openProvince(iso3, name) { + const t = provinceRows.value + if (!t || !name) return + selectedProvince.value = (t.rows || []).find((r) => r.iso3 === iso3 && r.province === name) || null +} +function closeProvince() { selectedProvince.value = null } + function pinFC() { const feats = new Array(pinRows.value.length) const hnrFor = (code) => { @@ -1367,6 +1473,7 @@ function setMode(m) { if (mode.value === m) return mode.value = m if (mapRef.value && adm0Ref.value) paint(mapRef.value, adm0Ref.value) + repaintProvinces() } /* ═══ NUMBER FORMAT IS HELD FOR THE DRIVER ════════════════════════════════════════════════ @@ -1883,7 +1990,34 @@ useShadowStyles(
{{ err }}
-
+ +
+
+ + +
+

{{ selectedProvince.province }}

+
{{ t('gospel.stat.peopleGroups') }}{{ fmt(selectedProvince.groups) }}
+
{{ t('gospel.stat.population') }}{{ fmt(selectedProvince.people) }}
+
{{ t('gospel.legend.tier0') }}{{ fmt(selectedProvince.zero_access_groups) }}
+
+ {{ t('gospel.stat.peopleAffected') }}{{ fmt(selectedProvince.zero_access_people) }} +
+

+ Largest with nothing: {{ selectedProvince.zero_access_examples }} + +

+

+ A group is placed in a province from its own coordinate, which marks where a people is + found rather than a settlement. Near a border that point can fall on the wrong side. +

+
+ +