scripts/gen-world-svg.js
#!/usr/bin/env node
// Explicit map refresh, never part of the ordinary offline site build.
// node scripts/gen-world-svg.js --fetch
// node scripts/gen-world-svg.js --cache /path/to/pinned/source/files
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { projectEqualEarth, equalEarthFrame } from '../assets/emergence-projection.js';
const root = path.resolve(import.meta.dirname, '..');
const sources = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, 'map-sources.json')));
const review = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, 'map-review.json')));
const cacheArg = process.argv.indexOf('--cache');
const cache = cacheArg < 0 ? path.join(root, '.cache', 'emergence-map') : path.resolve(process.argv[cacheArg + 1]);
const sourceData = {};
for (const source of sources.files) {
const file = path.join(cache, source.name + '.geojson');
if (!fs.existsSync(file)) {
if (!process.argv.includes('--fetch')) throw Error(`Missing ${source.name}; use --fetch for an explicit map refresh.`);
const response = await fetch(source.url);
if (!response.ok) throw Error(`${source.name}: HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (crypto.createHash('sha256').update(bytes).digest('hex') !== source.sha256) throw Error(`Source hash mismatch: ${source.name}`);
fs.mkdirSync(cache, { recursive: true });
fs.writeFileSync(file, bytes);
}
const bytes = fs.readFileSync(file);
if (crypto.createHash('sha256').update(bytes).digest('hex') !== source.sha256) throw Error(`Cached source hash mismatch: ${source.name}`);
sourceData[source.name] = JSON.parse(bytes);
}
const props = f => Object.fromEntries(Object.entries(f.properties).map(([k, v]) => [k.toLowerCase(), v]));
const round = n => Math.round(n * 100) / 100;
const coord = p => p.map(round).join(',');
const distanceSq = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const t = dx || dy ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy))) : 0;
return (p[0] - a[0] - t * dx) ** 2 + (p[1] - a[1] - t * dy) ** 2;
};
// Simplify in projected units: same visual tolerance at every latitude.
function simplify(points, tolerance = .07) {
if (points.length < 4) return points;
let max = tolerance * tolerance, index = 0;
for (let i = 1; i < points.length - 1; i++) {
const d = distanceSq(points[i], points[0], points.at(-1));
if (d > max) { max = d; index = i; }
}
return index ? [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)] : [points[0], points.at(-1)];
}
function projected(points) {
const dense = [];
for (let i = 0; i < points.length; i++) {
if (i) {
const a = points[i - 1], b = points[i];
// Preserve curved meridians on long pole/dateline edges.
const steps = Math.ceil(Math.max(Math.abs(b[1] - a[1]), Math.abs(b[0] - a[0])) / 3);
for (let j = 1; j < steps; j++) dense.push(projectEqualEarth(a[0] + (b[0] - a[0]) * j / steps, a[1] + (b[1] - a[1]) * j / steps));
}
dense.push(projectEqualEarth(...points[i]));
}
return dense;
}
const rings = g => g.type === 'Polygon' ? g.coordinates : g.type === 'MultiPolygon' ? g.coordinates.flat() : [];
function shape(g) {
return rings(g).map(points => {
const full = projected(points), small = simplify(full);
// Keep very small islands instead of reducing them to a zero-area segment.
const keep = small.length >= 4 ? small : full;
return 'M' + keep.map(coord).join('L') + 'Z';
}).join('');
}
function line(g) {
const parts = g.type === 'LineString' ? [g.coordinates] : g.type === 'MultiLineString' ? g.coordinates : [];
return parts.map(points => 'M' + simplify(projected(points)).map(coord).join('L')).join('');
}
function bounds(g) {
const pts = rings(g).flat().map(p => projectEqualEarth(...p));
return [Math.min(...pts.map(p => p[0])), Math.min(...pts.map(p => p[1])), Math.max(...pts.map(p => p[0])), Math.max(...pts.map(p => p[1]))].map(round);
}
const slug = s => s.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const clean = s => (s || '').replace(/\uFEFF/g, '').trim();
const excluded = new Map(review.excludeAreas.map(x => [x.id, x]));
const corrections = [...review.excludeAreas];
const regionByArea = new Map(review.regions.flatMap(r => r.areas.map(id => [id, r])));
const regions = new Map();
for (const f of sourceData.ne_10m_admin_0_disputed_areas.features) {
const p = props(f), kind = p.featurecla;
if (excluded.has(p.ne_id) || review.omitClasses.includes(kind)) continue;
const custom = regionByArea.get(p.ne_id);
const originalName = clean(p.brk_group || p.brk_name || p.name);
const name = custom?.name || review.aliases[originalName] || originalName;
const id = custom?.id || slug(name);
if (!id) throw Error(`Unnamed area ${p.ne_id}`);
if (!regions.has(id)) regions.set(id, {
id, name, category: custom?.category || (kind === 'Admin-0 overlay' ? 'Reference zone' : kind.startsWith('Indeterminant') ? 'Boundary uncertain' : 'Territorial claim / status'), reviewStatus: custom ? 'Source note checked' : 'Reference only',
reviewedAt: custom ? review.reviewedAt : null,
note: custom?.note || 'This area is flagged in Natural Earth’s reference data. Its present status has not been independently checked here. The outline does not assign sovereignty or show a live front line.',
sources: custom?.sources || [], paths: [], bounds: null, features: []
});
const region = regions.get(id), b = bounds(f.geometry);
region.paths.push(shape(f.geometry));
region.bounds = region.bounds ? [Math.min(region.bounds[0], b[0]), Math.min(region.bounds[1], b[1]), Math.max(region.bounds[2], b[2]), Math.max(region.bounds[3], b[3])] : b;
region.features.push({ id: p.ne_id, sourceClass: kind, sourceName: clean(p.brk_name || p.name) });
}
const boundaries = { ordinary: '', disputed: '', control: '', uncertain: '', claim: '', zone: '' };
const lineAudit = [];
for (const name of ['ne_10m_admin_0_boundary_lines_land', 'ne_10m_admin_0_boundary_lines_disputed_areas']) {
for (const f of sourceData[name].features) {
const p = props(f), kind = p.featurecla;
const omitted = review.omitClasses.includes(kind) || review.excludeLineNames.includes(p.name);
const type = omitted ? null : kind.startsWith('International boundary') ? 'ordinary'
: kind.startsWith('Disputed') || kind === 'Breakaway' ? 'disputed'
: kind.startsWith('Line of control') || kind === 'Unrecognized' && p.name === 'Line of control' ? 'control'
: kind.startsWith('Indefinite') || kind.startsWith('Indeterminant') || kind === 'Elusive frontier' ? 'uncertain'
: kind === 'Claim boundary' ? 'claim' : kind === 'Overlay limit' ? 'zone' : null;
lineAudit.push({ id: p.ne_id, source: name, sourceClass: kind, sourceName: p.name || null, renderedAs: type });
if (type) boundaries[type] += line(f.geometry);
}
}
const countries = {};
for (const f of sourceData.ne_50m_admin_0_countries.features) {
const p = props(f), iso = p.iso_a2_eh || p.iso_a2;
if (/^[A-Z]{2}$/.test(iso)) countries[iso] = (countries[iso] || '') + shape(f.geometry);
}
const land = sourceData.ne_50m_land.features.map(f => shape(f.geometry)).join('');
const regionList = [...regions.values()].sort((a, b) => a.name.localeCompare(b.name, 'en')).map(r => ({ ...r, d: r.paths.join(''), paths: undefined }));
const world = {
provenance: 'Natural Earth v5.1.2, public domain; physical land 1:50m, boundary and disputed-area reference geometry 1:10m. Not live territorial control.',
projection: equalEarthFrame, land, countries, boundaries, regions: regionList,
review: { date: review.reviewedAt, scope: review.scope, corrections, referenceUrl: 'https://www.naturalearthdata.com/about/disputed-boundaries-policy/' },
sources
};
fs.writeFileSync(path.join(root, 'assets', 'emergence-world.json'), JSON.stringify(world) + '\n');
fs.writeFileSync(path.join(import.meta.dirname, 'map-audit.json'), JSON.stringify({ sources, reviewDate: review.reviewedAt, scope: review.scope, lines: lineAudit, areas: regionList.map(({ d, ...r }) => r), corrections }, null, 2) + '\n');
console.log(`Generated Equal Earth: ${Object.keys(countries).length} country references, ${regionList.length} area notes; ${(JSON.stringify(world).length / 1024).toFixed(0)} KB.`);