source.fact.ngo a coherence.ngo project

scripts/gen-world-svg.js

raw ↗ · AGPL-3.0

#!/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.`);