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(
+ Largest with nothing: {{ selectedProvince.zero_access_examples }}
+
Way in: {{ selectedProvince.ways_in }} — chosen for the country, not this province.
+
+ 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. +
+