132 lines
6 KiB
JavaScript
132 lines
6 KiB
JavaScript
// emit-countries.mjs — build the COUNTRY dataset for the country-posters app.
|
|
//
|
|
// One entry per country that has at least one people group, carrying everything
|
|
// both the map and the poster wall need: the count, the region it files under, its
|
|
// bbox, and which sheet size it takes.
|
|
//
|
|
// node emit-countries.mjs
|
|
// -> ../../plugin-designs/country-posters/data/countries.json
|
|
//
|
|
// SHEET LADDER (design note 2026-08-01) — the count selects the SHEET SIZE, it does not
|
|
// exclude a country:
|
|
// <= OVERSIZE_AT 3ft x 3ft (172 countries, incl. Sudan 105 + Brazil 103)
|
|
// <= UNSUPPORTED_AT 3ft x 4ft (China, 154 — "lengthen it by one foot")
|
|
// > UNSUPPORTED_AT none (India, 855 — a not-yet card printing the EXACT number)
|
|
//
|
|
// EVERY qualifying country gets a sheet, including the 92 with exactly one group.
|
|
// Brief: "if it has a one-people-group on it, we still want a one-people-group map
|
|
// generated… those get sorted to the bottom of the list." The wall's descending
|
|
// sort is what handles them — NOT a floor and NOT a sparse template variant.
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const OUT_DIR = resolve(__dirname, '..', '..', '..', 'plugin-designs', 'country-posters', 'data');
|
|
const RECORDS = join(__dirname, 'out', '_records.json');
|
|
const ADM0 = resolve(__dirname, '..', '..', '..', 'plugin-designs', 'my-upg-100-list', 'data', 'adm0', 'world-adm0.geojson');
|
|
|
|
const OVERSIZE_AT = 120; // above this, the 3x4 sheet
|
|
const UNSUPPORTED_AT = 200; // above this, no single sheet fits legibly
|
|
|
|
const raw = JSON.parse(readFileSync(RECORDS, 'utf8'));
|
|
const rows = Array.isArray(raw) ? raw : (raw.records || raw.data || []);
|
|
|
|
// Country polygons, so a country with records but no boundary is detectable rather
|
|
// than silently absent — that gap (175 record countries vs 161 polygons) is real.
|
|
//
|
|
// MEASURED 2026-08-01 — the countries without a polygon are NOT random:
|
|
// IND (855) — India USED to be absent from world-adm0.geojson. It is now fetched
|
|
// at ADM0 like every other country (2026-08-02, data/source-boundaries.mjs), so
|
|
// hasGeometry is true for it. India still renders no country SHEET: that is
|
|
// gated on record count (855 > UNSUPPORTED_AT), not on geometry — a not-yet
|
|
// card printing the EXACT number.
|
|
// The rest are microstates/territories with 1-3 groups each: KOS ESH CCK CXR
|
|
// GIB JEY MCO NFK HKG MAC SXM SJM TKL. The pin-extent fallback is the right
|
|
// answer for those — a 1-group territory needs a view, not a boundary.
|
|
const adm0 = JSON.parse(readFileSync(ADM0, 'utf8'));
|
|
const haveGeom = new Set(adm0.features.map((f) => f.properties?.iso3).filter(Boolean));
|
|
|
|
const bboxOf = (geom) => {
|
|
let x0 = 180, y0 = 90, x1 = -180, y1 = -90;
|
|
const walk = (c) => {
|
|
if (typeof c[0] === 'number') {
|
|
if (c[0] < x0) x0 = c[0]; if (c[0] > x1) x1 = c[0];
|
|
if (c[1] < y0) y0 = c[1]; if (c[1] > y1) y1 = c[1];
|
|
} else for (const p of c) walk(p);
|
|
};
|
|
walk(geom.coordinates);
|
|
return [x0, y0, x1, y1];
|
|
};
|
|
const geomBbox = new Map();
|
|
for (const f of adm0.features) {
|
|
const k = f.properties?.iso3;
|
|
if (k) geomBbox.set(k, bboxOf(f.geometry));
|
|
}
|
|
|
|
// Group the records by ISO3.
|
|
const byIso = new Map();
|
|
for (const r of rows) {
|
|
const iso = r.country_code;
|
|
if (!iso) continue;
|
|
if (!byIso.has(iso)) byIso.set(iso, []);
|
|
byIso.get(iso).push(r);
|
|
}
|
|
|
|
const countries = [];
|
|
for (const [iso, list] of byIso) {
|
|
const count = list.length;
|
|
// The region every record agrees on (they do, in practice — take the first).
|
|
const region = list[0].wagf_region_label || list[0].wagf_region || 'Other';
|
|
const label = list[0].country_code_label || iso;
|
|
|
|
// Fall back to the PIN extent when there is no polygon, so a country without a
|
|
// boundary still has a usable view rather than defaulting to the whole globe.
|
|
let bbox = geomBbox.get(iso);
|
|
if (!bbox) {
|
|
const lng = list.map((r) => +r.longitude).filter(Number.isFinite);
|
|
const lat = list.map((r) => +r.latitude).filter(Number.isFinite);
|
|
bbox = lng.length ? [Math.min(...lng), Math.min(...lat), Math.max(...lng), Math.max(...lat)] : null;
|
|
}
|
|
|
|
const sheet = count > UNSUPPORTED_AT ? null : count > OVERSIZE_AT ? '3x4' : '3x3';
|
|
|
|
countries.push({
|
|
id: `country-${iso.toLowerCase()}`,
|
|
iso3: iso,
|
|
label,
|
|
count,
|
|
region: region === 'N/A' || !region ? 'Other' : region,
|
|
bbox,
|
|
sheet,
|
|
hasGeometry: haveGeom.has(iso),
|
|
// The not-yet reason is DATA, not UI copy, so the exact number travels with the
|
|
// record and cannot drift from what the wall prints.
|
|
// Brief: "it should say the exact number… we should say the exact number in our data."
|
|
unsupportedReason: sheet ? null
|
|
: `${label} has ${count.toLocaleString()} people groups — too many to fit one poster legibly. A solution is in progress.`,
|
|
});
|
|
}
|
|
|
|
countries.sort((a, b) => b.count - a.count);
|
|
|
|
const stats = {
|
|
countries: countries.length,
|
|
people_groups: rows.length,
|
|
withGeometry: countries.filter((c) => c.hasGeometry).length,
|
|
noGeometry: countries.filter((c) => !c.hasGeometry).length,
|
|
sheet_3x3: countries.filter((c) => c.sheet === '3x3').length,
|
|
sheet_3x4: countries.filter((c) => c.sheet === '3x4').length,
|
|
unsupported: countries.filter((c) => !c.sheet).length,
|
|
single_group: countries.filter((c) => c.count === 1).length,
|
|
generated: new Date().toISOString().slice(0, 10),
|
|
};
|
|
|
|
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
|
|
writeFileSync(join(OUT_DIR, 'countries.json'), JSON.stringify({ stats, countries }, null, 1));
|
|
|
|
console.log(`[countries] ${stats.countries} countries, ${stats.people_groups} people groups`);
|
|
console.log(`[countries] 3x3=${stats.sheet_3x3} 3x4=${stats.sheet_3x4} unsupported=${stats.unsupported}`);
|
|
console.log(`[countries] no polygon: ${stats.noGeometry} single-group: ${stats.single_group}`);
|
|
for (const c of countries.slice(0, 5)) console.log(` ${String(c.count).padStart(4)} ${c.label} [${c.sheet || 'NOT-YET'}]`);
|