253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
#!/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 `<ISO_A3>-<nn>` (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 = <ISO_A3>-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();
|