/** * scripts/build-region-geo.mjs — build the region **geo** manifest: * `public/region-geo.json` = `{ "": { "area": , "population": }, ... }`. * * For every sub-region (region key, see `public/region-keys.json`) this records * two facts the v0.28 per-region stats model (and its area×population weighting) * needs: * - `area` — the region's **total** surface area in km², from * `@turf/area` on the FINAL generated feature * (`public/regions.geo.json`) — turf reports square metres, * so the raw value is divided by 1e6 — rounded to 2 decimals. * - `population` — the region's share of its **parent country's** real * population (Natural Earth 1:10m admin-0 `POP_EST`, from the * committed `public/ne-admin0-pop.json`), distributed across * the country's sub-regions **proportional to each region's * area** (a first-order "uniform population density within a * country" approximation — Natural Earth admin-1, which the * region generator uses, carries no per-unit population). * * Population distribution uses **largest remainder** per country, so the sum of * a country's sub-region populations is EXACTLY the country's POP_EST, and the * sum over all 1,800 regions is EXACTLY the sum of the 177 repo countries' * POP_EST (7,654,092,021) — no rounding drift. * * Method — plain turf on the pre-generated geo file (turf is allowed in .mjs * scripts; it is deliberately kept OUT of the TS typecheck program, see the * v0.17 note). The population source is the COMMITTED `public/ne-admin0-pop.json` * (a small derived table produced once by `scripts/extract-admin0-pop.mjs` from * the 13 MB Natural Earth admin-0 file, which we do NOT commit) — so this build * is fully reproducible in CI. * * Output contract (asserted by scripts/region-geo.test.ts): * - one entry for **every** region key, keyed identically to * `public/region-keys.json` (same key set, 1,800 entries); * - `area` is a finite number >= 0 (km², <= 2 decimals); * - `population` is a non-negative integer; * - the total population across all entries is a non-negative integer. * * Deterministic + idempotent: fixed inputs (the geo file, the key manifest, and * the committed population table), pure turf, largest-remainder with a * (remainder desc, key asc) tie-break, and stable sorted key order → a re-run * rewrites a byte-identical file (the test re-runs this script and compares * bytes). * * Usage: `node scripts/build-region-geo.mjs` (or `npm run build:region-geo`). */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import area from '@turf/area'; import { feature } from '@turf/helpers'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); const round2 = (v) => Math.round(v * 100) / 100; // --- load the inputs (plain parse) ------------------------------------------ const geoPath = path.join(root, 'public', 'regions.geo.json'); const keysPath = path.join(root, 'public', 'region-keys.json'); const popPath = path.join(root, 'public', 'ne-admin0-pop.json'); let geo, idToKey, popByCountry; try { geo = JSON.parse(readFileSync(geoPath, 'utf8')); idToKey = JSON.parse(readFileSync(keysPath, 'utf8')); popByCountry = JSON.parse(readFileSync(popPath, 'utf8')); } catch (err) { console.error( `[build-region-geo] could not load the inputs (${geoPath}, ${keysPath}, ${popPath}). ` + `Is public/ne-admin0-pop.json present? Regenerate it with scripts/extract-admin0-pop.mjs.`, ); throw err; } /** * One pass over the geo features: for each region key record its (unrounded) * turf area and its parent ISO_A3, then group the keys by parent so the * population can be distributed per country. */ const regionArea = new Map(); // key -> unrounded area (km²) const regionParent = new Map(); // key -> parent ISO_A3 for (const f of geo.features ?? []) { const id = f.id == null ? '' : String(f.id); const key = idToKey[id]; if (!key) continue; // a geo feature with no manifest entry is skipped // @turf/area reports square METRES; the manifest stores km². const a = area(feature(f.geometry)) / 1e6; regionArea.set(key, a); regionParent.set(key, typeof f.properties?.parent === 'string' ? f.properties.parent : ''); } // Group region keys by parent country (file order is stable, but we sort for // deterministic population distribution). const keysByCountry = new Map(); // parent -> string[] of keys for (const [key, parent] of regionParent) { if (!parent) continue; // a region with no parent has no population source const list = keysByCountry.get(parent); if (list) list.push(key); else keysByCountry.set(parent, [key]); } const population = new Map(); // key -> integer population for (const [parent, keys] of keysByCountry) { keys.sort(); // deterministic ordering for the tie-break const P = Number.isFinite(popByCountry[parent]) ? Math.max(0, popByCountry[parent]) : 0; const totalArea = keys.reduce((s, k) => s + (regionArea.get(k) || 0), 0); if (P <= 0 || totalArea <= 0) { for (const k of keys) population.set(k, 0); continue; } // Largest remainder: floor each share, then hand out the leftover 1s to the // regions with the largest fractional remainders (ties broken by key asc). const shares = keys.map((k) => { const a = regionArea.get(k) || 0; const ideal = (P * a) / totalArea; return { k, floor: Math.floor(ideal), rem: ideal - Math.floor(ideal) }; }); const distributed = shares.reduce((s, x) => s + x.floor, 0); let deficit = P - distributed; // 0 <= deficit < keys.length const order = shares .map((x, i) => ({ i, rem: x.rem, k: x.k })) .sort((a, b) => (b.rem - a.rem) || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0)); for (let i = 0; i < order.length && deficit > 0; i++) { shares[order[i].i].floor += 1; deficit -= 1; } for (const x of shares) population.set(x.k, x.floor); } // --- assemble the manifest (sorted keys) ------------------------------------- const expectedKeys = Object.values(idToKey); const manifest = {}; for (const k of [...expectedKeys].sort()) { const a = regionArea.get(k); const p = population.get(k); if (a === undefined || p === undefined) { throw new Error(`region key ${JSON.stringify(k)} has no computed area/population`); } manifest[k] = { area: round2(a), population: p }; } // --- hard assertions (the test re-checks these against the committed file) ---- if (Object.keys(manifest).length !== expectedKeys.length) { throw new Error( `expected ${expectedKeys.length} region-geo entries (one per region key), got ${Object.keys(manifest).length}`, ); } let totalPop = 0; let zeros = 0; for (const [k, v] of Object.entries(manifest)) { if (!Number.isFinite(v.area) || v.area < 0) throw new Error(`bad area for ${k}: ${JSON.stringify(v)}`); if (!Number.isInteger(v.population) || v.population < 0) throw new Error(`bad population for ${k}: ${JSON.stringify(v)}`); if (v.area === 0) zeros += 1; totalPop += v.population; } // --- write idempotently (sorted keys, 2-space indent, trailing newline) ------ writeFileSync(path.join(root, 'public', 'region-geo.json'), JSON.stringify(manifest, null, 2) + '\n'); // --- summary ------------------------------------------------------------------ console.log( `[build-region-geo] wrote public/region-geo.json — ${Object.keys(manifest).length} region keys` + `${zeros ? ` (${zeros} zero-area regions)` : ''}, total population ${totalPop.toLocaleString('en-US')}`, );