#!/usr/bin/env node /** * scripts/smooth-regions.mjs — smooths jagged sub-national region boundaries. * * Run: npm run smooth:regions (in place on public/regions.geo.json) * node scripts/smooth-regions.mjs [out] [src] * * The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization * left some sub-national boundaries overly jagged — the worst being coastal / * borderland regions that the dissolve split into many small, zig-zag fragments * (e.g. SI-048 Kocevje, Slovenia, 57 parts; MK-36 Kavadartsi, N. Macedonia, 8 * parts). This cleans the jaggedness in place with **Douglas–Peucker * vertex-thinning** (`@turf/simplify`), applied to every ring of every polygon. * * DOUGLAS–PEUCKER — `@turf/simplify` keeps a vertex only when it deviates from * the chord joining its kept neighbours by more than the tolerance, removing * the redundant "in-between" points of a shallow zig-zag. The tolerance is * expressed in the GeoJSON degree space (its distance metric treats 1° lon * == 1° lat); TOLERANCE_M (metres) is converted with the same ~111 km/° the * area model uses, so TOLERANCE_M = 50 is a gentle ~50 m pass. Bump it to * 100 / 200 for a stronger pass and re-run — the pass is idempotent either way. * * VERTEX-THINNING ONLY — DP only removes points. It never moves a border, * never merges/dissolves regions or parts, and never creates or removes a * ring, so topology is preserved exactly: the region count, every `id`, * `properties.name`/`properties.parent`, the part count of every feature, the * single real hole (KZ-KZY), and all 83 islands are all unchanged. Only the * redundant vertices along each ring are dropped. * * Deterministic + idempotent: one @turf/simplify pass at a fixed tolerance, then * 4-dp rounding (the same convention as the other scripts). Because the output * is already at the tolerance's fixed point, re-running on the smoothed file * yields a byte-identical file (sha256-verified). The 18 degenerate 3-point * micro-rings (the ~0-area "dots" like Ragged Island / the Paracels) are kept * verbatim — @turf/simplify requires ≥4 points and would otherwise throw — so * no ring is ever dropped and every feature keeps its exact part/ring count. */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { simplify } from '@turf/simplify'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); const DEFAULT_FILE = path.join(root, 'public', 'regions.geo.json'); const args = process.argv.slice(2).filter((a) => !a.startsWith('-')); const OUT = args[0] ? path.resolve(args[0]) : DEFAULT_FILE; const SRC = args[1] ? path.resolve(args[1]) : DEFAULT_FILE; // --- parameters --------------------------------------------------------------- const TOLERANCE_M = 50; // DP tolerance in metres — ~50 m is gentle (bump to 100/200 for more) const M_PER_DEG = 111_000; // ~111 km per degree (same scale as the area model's 111.32/110.574) const TOL_DEG = TOLERANCE_M / M_PER_DEG; // @turf/simplify works in degree space → ≈ 0.00045° const R4 = 4; // coordinate precision (decimal places) — same as the other scripts // --- rounding / normalization (same conventions as the other scripts) ---------- const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N; function roundRing(ring, N = R4) { 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); } 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 ring; // degenerate — keep verbatim, never corrupt a ring return [...out, [...out[0]]]; } // --- tiny geometry helpers ----------------------------------------------------- const polysOf = (g) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates); const ringsOf = (g) => polysOf(g).flatMap((p) => p); const countVerts = (g) => ringsOf(g).reduce((s, r) => s + r.length, 0); const countParts = (g) => (g.type === 'Polygon' ? 1 : g.coordinates.length); const countRings = (g) => ringsOf(g).length; const countHoles = (g) => polysOf(g).reduce((s, p) => s + Math.max(0, p.length - 1), 0); /** * Smooth one closed ring with a single @turf/simplify (Douglas–Peucker) pass. * Only removes vertices — never adds, never drops the ring. Degenerate rings * (<4 pts) are returned verbatim (simplify would throw); a ring DP would * over-collapse below a valid 4-pt ring is kept as-is; any error keeps the * original. Never throws. */ function smoothRing(ring) { if (!Array.isArray(ring) || ring.length < 4) return ring; // keep degenerate verbatim try { const feat = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } }; const next = simplify(feat, { tolerance: TOL_DEG }).geometry.coordinates[0]; if (!Array.isArray(next) || next.length < 4) return ring; // over-collapsed — keep original const closed = next[next.length - 1][0] === next[0][0] && next[next.length - 1][1] === next[0][1] ? next : [...next, [next[0][0], next[0][1]]]; const rounded = roundRing(closed); return rounded.length >= 4 ? rounded : ring; } catch { return ring; } } /** Smooth every ring of a geometry. Never drops a ring or a part. */ function smoothGeom(geom) { const newPolys = polysOf(geom).map((poly) => poly.map(smoothRing)); if (geom.type === 'Polygon' && newPolys.length === 1) return { type: 'Polygon', coordinates: newPolys[0] }; return { type: 'MultiPolygon', coordinates: newPolys }; } // --- main ---------------------------------------------------------------------- function main() { const t0 = Date.now(); const rawIn = readFileSync(SRC); const fc = JSON.parse(rawIn); const inBytes = Buffer.byteLength(rawIn); console.log(`input: ${fc.features.length} regions (${(inBytes / 1024).toFixed(0)} KB) from ${SRC}`); console.log(`mode: Douglas–Peucker via @turf/simplify, tolerance ${TOLERANCE_M} m (≈ ${TOL_DEG}°)`); // before stats let vb = 0, hb = 0, pb = 0, rb = 0, degenerate = 0; for (const f of fc.features) { vb += countVerts(f.geometry); hb += countHoles(f.geometry); pb += countParts(f.geometry); rb += countRings(f.geometry); for (const r of ringsOf(f.geometry)) if (r.length < 4) degenerate++; } const outFeatures = fc.features.map((f) => { const g = smoothGeom(f.geometry); return { type: 'Feature', id: f.id, properties: { name: f.properties.name, parent: f.properties.parent }, geometry: g }; }); const out = { type: 'FeatureCollection', features: outFeatures }; // after stats + change count (compare against the original, in order) let va = 0, ha = 0, pa = 0, ra = 0, changed = 0; for (let i = 0; i < out.features.length; i++) { const f = out.features[i]; va += countVerts(f.geometry); ha += countHoles(f.geometry); pa += countParts(f.geometry); ra += countRings(f.geometry); if (JSON.stringify(f.geometry) !== JSON.stringify(fc.features[i].geometry)) changed++; } const afterVerts = new Map(out.features.map((f) => [f.id, countVerts(f.geometry)])); const json = JSON.stringify(out); writeFileSync(OUT, json + '\n'); const outBytes = Buffer.byteLength(json) + 1; console.log(`\n--- RESULTS ---`); console.log(`regions: ${fc.features.length} → ${out.features.length} (unchanged)`); console.log(`vertices (incl. ring closure): ${vb} → ${va} removed ${vb - va} (${(100 * (vb - va) / vb).toFixed(2)}%)`); console.log(`rings: ${rb} → ${ra} parts: ${pb} → ${pa} holes: ${hb} → ${ha} (all unchanged)`); console.log(`regions whose geometry changed: ${changed} / ${fc.features.length}`); console.log(`degenerate rings (<4 pts, kept verbatim): ${degenerate}`); console.log(`file: ${(inBytes / 1024).toFixed(0)} KB → ${(outBytes / 1024).toFixed(0)} KB (${(outBytes / 1024 / 1024).toFixed(2)} MB)`); // Diagnose the "messy" regions: the real problem is fragmentation — the // dissolve left coherent regions as many disconnected parts. Rank by part // count (then total vertices) so these surface; Kocelje/Kavadartsi are here. const messy = fc.features .map((f) => ({ id: f.id, name: f.properties.name, parent: f.properties.parent, parts: countParts(f.geometry), vb: countVerts(f.geometry), })) .sort((a, b) => b.parts - a.parts || b.vb - a.vb) .slice(0, 15); console.log(`\nMOST-MESSY (top ${messy.length} by fragmentation = parts, then vertices):`); console.log(` id name country parts verts verts(after)`); for (const m of messy) console.log(` ${m.id.padEnd(10)} ${m.name.slice(0, 37).padEnd(38)} ${m.parent.padEnd(7)} ${String(m.parts).padStart(4)} ${String(m.vb).padStart(5)} ${String(afterVerts.get(m.id)).padStart(5)}`); console.log(`\nwrote ${OUT}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`); } main();