#!/usr/bin/env node /** * scripts/generate-regions.mjs — dependency-free generator for `public/regions.geo.json`. * * Builds the sub-national region choropleth from REAL named administrative * regions (Natural Earth admin-1: states, provinces, governorates, oblasts, …) * instead of the old geometric grid the user rejected ("some stupid geometric * grid" — it looked abstract, not like recognizable territories). * * Sources (both GeoJSON): * (a) the Natural Earth admin-1 file — real, named sub-national boundaries. * It is read from a NON-committed temp path (default /tmp/ne10m_admin1.geojson, * override with the NE_ADMIN1 env var) so the repo only stores the derived * output, not the 40 MB source. Natural Earth is public domain (CC0). * (b) the committed `public/countries.geo.json` (177 countries) — the * authoritative ISO_A3 set, the country names, and the whole-country * geometries used as the fallback for any country with no admin-1 data. * * Output contract (UNCHANGED from the old generator, so `WorldMap.tsx`, * `App.tsx`, `shared/protocol.ts` and `server/map.ts` all keep working — * ownership is resolved via `properties.parent`, the parent country's ISO_A3): * - a GeoJSON FeatureCollection * - each feature: a unique top-level `id`, `properties = { name, parent }` * (`parent` = the country ISO_A3 = the RegionMap key; `name` = the region's * human name), and a Polygon/MultiPolygon geometry. * * For each country in `countries.geo.json` (in file order, deterministic): * - if it has admin-1 sub-regions -> emit each as a feature, in the source * file's order. Region `id` = its ISO 3166-2 code (e.g. `US-CA`, `FR-75`, * `RU-MOW`) when present, else `-` (1-based index in file * order). Because ISO 3166-2 codes are not always unique WITHIN a country * (e.g. all nine of Bosnia's entities report `BA-BIH`), ids are * disambiguated with a `-2/-3/…` suffix on collision (BA-BIH, BA-BIH-2, …). * - if it has none (tiny / not covered by admin-1) -> emit ONE whole-country * region: `id = -01`, `name` = the country's name, geometry = the * country's geometry. * * Every coordinate is rounded to 4 decimal places (0.0001° ≈ 11 m). If the * resulting file exceeds ~8 MB, a light Douglas–Peucker pass (0.05°, escalating * through a small table if still too big) trims it under the guard while keeping * shapes recognizable, and the before/after sizes are reported. Features are * sorted by (parent, id). Fully deterministic: same inputs -> same bytes. * * Run: `npm run generate:regions` */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); // --- parameters ---------------------------------------------------------------- const SIZE_GUARD_KB = 8192; // ~8 MB file-size guard (task spec) const SIZE_TARGET_KB = 6144; // land under ~6 MB once simplified (margin under the guard) const DP_STEPS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]; // Douglas–Peucker tolerances (degrees) const R4 = 4; // base coordinate precision (decimal places, ≈11 m) // Natural Earth admin-1 source (NON-committed temp file; override with NE_ADMIN1). const INPUT = process.env.NE_ADMIN1 || '/tmp/ne10m_admin1.geojson'; // --- small geo helpers --------------------------------------------------------- const rN = (v, N) => Math.round(v * Math.pow(10, N)) / Math.pow(10, N); /** Round a ring to N decimals, drop consecutive duplicates, and close it. */ function roundRing(ring, N) { const out = []; for (const [x, y] of ring) { const p = [rN(x, N), rN(y, N)]; const last = out[out.length - 1]; if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p); } // drop a trailing point equal to the first (a redundant close), then re-close if ( out.length > 1 && out[0][0] === out[out.length - 1][0] && out[0][1] === out[out.length - 1][1] ) out.pop(); if (out.length < 3) return out; // degenerate; caller drops it return [...out, [...out[0]]]; } /** Perpendicular distance (degrees) from point p to the segment a→b. */ function perpDist(p, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const l2 = dx * dx + dy * dy; if (l2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]); let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2; t = Math.max(0, Math.min(1, t)); return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); } /** Douglas–Peucker simplification of an OPEN point sequence (keeps endpoints). */ function dpOpen(pts, tol) { if (pts.length < 3) return pts; let maxD = 0; let idx = 0; const a = pts[0]; const b = pts[pts.length - 1]; for (let i = 1; i < pts.length - 1; i++) { const d = perpDist(pts[i], a, b); if (d > maxD) { maxD = d; idx = i; } } if (maxD > tol) { const left = dpOpen(pts.slice(0, idx + 1), tol); const right = dpOpen(pts.slice(idx), tol); return left.concat(right.slice(1)); } return [a, b]; } /** Douglas–Peucker a CLOSED ring (first == last) at tolerance `tol` (0 = skip). */ function dpRing(ring, tol) { if (tol <= 0 || ring.length < 3) return ring; let pts = ring; if ( pts[0][0] === pts[pts.length - 1][0] && pts[0][1] === pts[pts.length - 1][1] ) pts = pts.slice(0, -1); const simplified = dpOpen(pts, tol); return [...simplified, [...simplified[0]]]; } /** Round (and optionally simplify) every ring of a Polygon/MultiPolygon geometry. */ function transformGeom(geom, tol) { const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; const out = polys .map((poly) => poly.map((ring) => roundRing(ring, R4)).map((ring) => (tol > 0 ? dpRing(ring, tol) : ring))) .filter((poly) => poly.length >= 1 && poly.every((ring) => ring.length >= 3)); if (!out.length) return geom; // nothing survived simplification — keep the rounded original if (geom.type === 'Polygon') return { type: 'Polygon', coordinates: out[0] }; return { type: 'MultiPolygon', coordinates: out }; } // --- main ---------------------------------------------------------------------- function main() { const countries = JSON.parse(readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8')); const admin1 = JSON.parse(readFileSync(INPUT, 'utf8')); const cset = new Set(countries.features.map((c) => c.id)); // Group admin-1 features by parent ISO_A3 (their `adm0_a3`), in file order, // keeping only parents that are one of our 177 countries. const byParent = new Map(); for (const f of admin1.features) { const p = f.properties.adm0_a3; if (typeof p === 'string' && cset.has(p)) { if (!byParent.has(p)) byParent.set(p, []); byParent.get(p).push(f); } } // Build the base specs (raw geometry + unique id + name + parent) in // countries.geo.json file order, then per-region source order — deterministic. const specs = []; const seenIds = new Set(); const nextId = (base) => { let id = base; let k = 1; while (seenIds.has(id)) { k += 1; id = `${base}-${k}`; } seenIds.add(id); return id; }; let fallbackCount = 0; const fallbackCountries = []; for (const c of countries.features) { const regs = byParent.get(c.id) ?? []; if (regs.length) { let nn = 0; for (const r of regs) { nn += 1; const iso = r.properties.iso_3166_2; const base = typeof iso === 'string' && iso ? iso : `${c.id}-${String(nn).padStart(2, '0')}`; const name = typeof r.properties.name === 'string' && r.properties.name ? r.properties.name : c.properties.name; specs.push({ id: nextId(base), name, parent: c.id, geom: r.geometry }); } } else { fallbackCount += 1; fallbackCountries.push(c.id); specs.push({ id: nextId(`${c.id}-01`), name: c.properties.name, parent: c.id, geom: c.geometry }); } } const sortById = (a, b) => (a.properties.parent !== b.properties.parent ? (a.properties.parent < b.properties.parent ? -1 : 1) : a.id.localeCompare(b.id, 'en', { numeric: true })); const build = (tol) => { const fc = { type: 'FeatureCollection', features: specs.map((s) => ({ type: 'Feature', id: s.id, properties: { name: s.name, parent: s.parent }, geometry: transformGeom(s.geom, tol), })), }; fc.features.sort(sortById); return fc; }; // Base: 4 decimal places, no simplification. let fc = build(0); let json = JSON.stringify(fc); let sizeKB = Buffer.byteLength(json) / 1024; console.log(`base (4dp, no DP): ${sizeKB.toFixed(1)} KB (${fc.features.length} features)`); // File-size guard: if the base is over the guard, simplify until it is under // the target (or we exhaust the tolerance table). let usedTol = 0; if (sizeKB > SIZE_GUARD_KB) { for (const t of DP_STEPS) { fc = build(t); json = JSON.stringify(fc); sizeKB = Buffer.byteLength(json) / 1024; usedTol = t; console.log(`DP ${t}: ${sizeKB.toFixed(1)} KB`); if (sizeKB < SIZE_TARGET_KB) break; } } writeFileSync(path.join(root, 'public', 'regions.geo.json'), json + '\n'); // --- summary ---------------------------------------------------------------- const counts = new Map(); for (const f of fc.features) counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1); const sortedCounts = [...counts.values()].sort((a, b) => a - b); const median = sortedCounts.length % 2 ? sortedCounts[Math.floor(sortedCounts.length / 2)] : (sortedCounts[sortedCounts.length / 2 - 1] + sortedCounts[sortedCounts.length / 2]) / 2; const maxCountry = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]; const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? ''; const finalKB = Buffer.byteLength(json) / 1024; console.log(`\nwrote public/regions.geo.json — ${finalKB.toFixed(1)} KB (${(finalKB / 1024).toFixed(2)} MB)`); console.log(`features: ${fc.features.length} total across ${counts.size} of ${countries.features.length} countries`); console.log(`per-country count: min ${sortedCounts[0]} / median ${median} / max ${sortedCounts[sortedCounts.length - 1]}`); console.log(`max-count country: ${maxCountry[0]} (${maxCountryName}) with ${maxCountry[1]}`); if (usedTol) console.log(`simplification: Douglas–Peucker @ ${usedTol}° (base was ${SIZE_GUARD_KB}+ KB)`); console.log(`whole-country fallback: ${fallbackCount} ${fallbackCount ? `(${fallbackCountries.join(', ')})` : '(none)'}`); } main();