#!/usr/bin/env node /** * scripts/coarsen-regions.mjs — coarsens the committed `public/regions.geo.json` * in place, so the map is genuinely coarser, not just finer-grained. * * Rule (per user request: "focus on joining cities to the region around it. * Regions shouldn't be smaller than around 100x100km, excluding islands"): * * FLOOR — no region should be smaller than ~100×100 km = 10,000 km², * EXCEPT islands. * ISLAND — a region is an island if it has NO same-country neighbor: no * other region with the same `properties.parent` whose bounding box * is within CLOSE_DEG (≈≤22 km) of its own. Small islands stay * as-is regardless of size (Corsica, the Hawaiian counties, * Okinawa, the Faroes, island micro-states, …). * MERGE — a region that is small (area < FLOOR) AND not an island is * merged into its NEAREST same-country neighbor (nearest by * centroid distance; tie-break → larger area, then lexicographic * id). Standalone cities (FR-75 Paris, the Latvian/Slovenian * municipalities, the UK's 231 micro-districts) collapse into the * region around them. * DISSOLVE— the merge is a real boolean union (`@turf/union`, polygon- * clipping): the shared boundary between the merged region and its * host is removed, producing a single clean polygon — not two * overlapping shapes. (Fallback if the union fails on a pair: * combine the rings into a MultiPolygon without dissolving; the * count of such fallbacks is reported.) * ITERATE — passes run smallest-area-first (small regions merge into their * neighbors, which grow and then absorb more). After each pass the * area/neighbor/centroid metrics are recomputed; it stops at the * fixed point (no small non-island region left) or at MAX_ITER. * IDENTITY— the host (the region merged INTO) keeps its own `id` and * `name`; the absorbed region's id is dropped. `properties.parent` * is unchanged, so the map code (WorldMap/App/protocol/map.ts) * keeps working untouched. * * Deterministic: fixed processing order (area, then id), deterministic * neighbor choice (distance, then -area, then id), deterministic boolean * union. Re-running on the coarsened file reaches the fixed point * immediately and rewrites a byte-identical file. * * Area model: the SAME planar approximation the validation test uses * (`scripts/regions.test.ts`) — `x = lon·111.32·cos(midLat)`, * `y = lat·110.574` km, shoelace per OUTER ring (holes ignored), each ring at * its own mean latitude — so the script's merge decisions and the test's * assertions measure the same quantity. * * Longitude handling: every ring is unwrapped to a continuous [0,360)-derived * space before its bbox/centroid is computed (the shortest-difference * "np.unwrap" scheme), which makes the one full-360° antimeridian-wrapping * feature (Antarctica's mainland ring) and any seam-straddling ring behave * correctly; cross-feature longitude gaps use the 360°-cyclic minimum. * * Output: features sorted by (parent, id) (the same comparator as * `scripts/generate-regions.mjs`), coordinates rounded to 4 decimal places, * single line of JSON + trailing newline. If the file would exceed the * ~5 MB guard, a light Douglas–Peucker pass (tolerances escalated through a * small table) brings it under ~4 MB before writing. * * Run: `npm run coarsen:regions` */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { union } from '@turf/union'; import { feature, featureCollection } from '@turf/helpers'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); const FILE = path.join(root, 'public', 'regions.geo.json'); // --- parameters --------------------------------------------------------------- const AREA_FLOOR_KM2 = 10_000; // ~100×100 km floor for non-island regions const CLOSE_DEG = 0.2; // "spatially close" bbox gap (both lat and lon ≤ this; ≈≤22 km) const MAX_ITER = 20; // safety cap on fixed-point passes (spec: ~15) const SIZE_GUARD_KB = 5_120; // keep the committed file under ~5 MB const SIZE_TARGET_KB = 4_096; // …and land under ~4 MB once simplified const DP_STEPS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]; // Douglas–Peucker tolerances (degrees) const R4 = 4; // coordinate precision (decimal places, ≈11 m) const SLIVER_EPS_KM2 = 1e-6; // drop only truly-degenerate (≈zero-area) clipper slivers // --- area model (identical to scripts/regions.test.ts) ------------------------- const KM_LON = 111.32; const KM_LAT = 110.574; const rad = (deg) => (deg * Math.PI) / 180; /** Shoelace area (km²) of one ring, measured at the ring's own mean latitude. */ function ringAreaKm2(ring) { let lat = 0; for (const [, la] of ring) lat += la; const kx = KM_LON * Math.cos(rad(ring.length ? lat / ring.length : 0)); let a = 0; const n = ring.length; for (let i = 0; i < n; i++) { const [lon1, lat1] = ring[i]; const [lon2, lat2] = ring[(i + 1) % n]; a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT); } return Math.abs(a / 2); } /** Sum of a geometry's outer-ring areas (holes are lakes — ignored by design). */ function geomAreaKm2(geom) { if (!geom) return 0; const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; let total = 0; for (const poly of polys) total += ringAreaKm2(poly[0]); return total; } // --- longitude unwrapping (antimeridian-safe) ---------------------------------- /** * Unwrap a ring's longitudes to a continuous real line (the "np.unwrap" * shortest-difference scheme, anchored at the first vertex's [0,360) value). * Returns { lons, full } — `full` when the unwrapped span is ≥ 300°, i.e. the * ring is a full-360° antimeridian band (Antarctica's mainland ring). */ function unwrapRing(ring) { const n = ring.length; const norm = (x) => ((x % 360) + 360) % 360; // → [0,360) const u = new Array(n); u[0] = norm(ring[0][0]); for (let i = 1; i < n; i++) { // The PREVIOUS unwrapped value may lie outside [0,360) (it accumulates), // so normalize it back before measuring the difference; otherwise a bogus // ~180° jump is inserted and the ring's span balloons to ~180°. const a = norm(u[i - 1]); const b = norm(ring[i][0]); const d = (((b - a) % 360) + 540) % 360 - 180; // shortest signed diff in (-180, 180] u[i] = u[i - 1] + d; } let min = Infinity; let max = -Infinity; for (const v of u) { if (v < min) min = v; if (v > max) max = v; } return { lons: u, full: max - min >= 300 }; } /** * Cyclic 1-D gap (degrees) between two tight longitude intervals in their own * unwrapped [0,360)-derived spaces. Tries the three 360° shifts of `b` so * different unwrap anchors reconcile; a `full` interval is close to everything. */ function lonGap(intA, intB) { if (intA.full || intB.full) return 0; let best = Infinity; for (const k of [-1, 0, 1]) { const a2 = intB.a + 360 * k; const b2 = intB.b + 360 * k; const gap = intA.b < a2 ? a2 - intA.b : b2 < intA.a ? intA.a - b2 : 0; if (gap < best) best = gap; } return best; } // --- geometry helpers ---------------------------------------------------------- const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N; /** Round a ring to 4 dp, drop consecutive duplicates, and (re-)close it. */ 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 out; // degenerate; caller drops it return [...out, [...out[0]]]; } /** * Round every ring of a geometry to 4 dp, drop degenerate rings (<3 pts) and * empty polygons, keep the Polygon/MultiPolygon type (a single-part * MultiPolygon collapses to Polygon). Returns null if nothing usable survived. */ function roundAll(geom) { if (!geom || (geom.type !== 'Polygon' && geom.type !== 'MultiPolygon')) return null; const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) .map((poly) => poly.map((ring) => roundRing(ring))) .filter((poly) => poly.length >= 1 && poly.every((r) => r.length >= 3)); if (!polys.length) return null; if (polys.length === 1 && geom.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] }; return { type: 'MultiPolygon', coordinates: polys }; } /** * Normalize a union output (or fallback MultiPolygon): roundAll, then drop * truly-degenerate (≈zero-area, < SLIVER_EPS_KM2) polygons the clipper can * leave behind at shared boundaries. It never drops *every* part — a merge of * two sub-resolution (zero-area) micro-regions must still collapse into one * (degenerate) region rather than vanish. Returns null only when the input * itself had no usable ring (e.g. `null`). */ function normalizeGeom(geom) { const rounded = roundAll(geom); if (!rounded) return null; const all = rounded.type === 'Polygon' ? [rounded.coordinates] : rounded.coordinates; const kept = all.filter((poly) => ringAreaKm2(poly[0]) >= SLIVER_EPS_KM2); const polys = kept.length ? kept : all; if (polys.length === 1 && rounded.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] }; return { type: 'MultiPolygon', coordinates: polys }; } /** Douglas–Peucker 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 dx = b[0] - a[0]; const dy = b[1] - a[1]; const l2 = dx * dx + dy * dy; let t = l2 === 0 ? 0 : ((pts[i][0] - a[0]) * dx + (pts[i][1] - a[1]) * dy) / l2; t = Math.max(0, Math.min(1, t)); const d = Math.hypot(pts[i][0] - (a[0] + t * dx), pts[i][1] - (a[1] + t * dy)); 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 at tolerance `tol` (0 = skip). */ function dpRing(ring, tol) { if (tol <= 0 || ring.length < 4) 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 s = dpOpen(pts, tol); if (s.length < 3) return ring; return [...s, [...s[0]]]; } /** Round (and optionally DP-simplify) every ring of a geometry. */ function transformGeom(geom, tol) { const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) .map((poly) => poly.map((ring) => roundRing(ring, R4)).map((ring) => (tol > 0 ? dpRing(ring, tol) : ring))) .filter((poly) => poly.length >= 1 && poly.every((r) => r.length >= 3)); if (!polys.length) return null; if (polys.length === 1 && geom.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] }; return { type: 'MultiPolygon', coordinates: polys }; } // --- region record ------------------------------------------------------------- /** * A region under management. `lonInt` is the tight longitude interval in * [0,360)-derived unwrapped space (or `full` for a 360° band); `(clat, clon)` * is the area-weighted centroid of the LARGEST outer ring (the region's * "location"), unwrapped for its ring then wrapped to [-180,180) for * cyclic-distance use. */ class Region { constructor(f) { this.id = f.id; this.name = f.properties.name; this.parent = f.properties.parent; this.geom = f.geometry; this.rawGeom = null; // last pre-normalization union output (safety net for build()) this.dead = false; this.metrics(); } metrics() { this.area = geomAreaKm2(this.geom); const polys = this.geom.type === 'Polygon' ? [this.geom.coordinates] : this.geom.coordinates; let latMin = Infinity; let latMax = -Infinity; let lonA = Infinity; let lonB = -Infinity; let lonFull = false; let bestRing = null; let bestArea = -1; for (const poly of polys) { const outer = poly[0]; for (const [, la] of outer) { if (la < latMin) latMin = la; if (la > latMax) latMax = la; } const { lons, full } = unwrapRing(outer); if (full) lonFull = true; let a = Infinity; let b = -Infinity; for (const v of lons) { if (v < a) a = v; if (v > b) b = v; } lonA = Math.min(lonA, a); lonB = Math.max(lonB, b); const ar = ringAreaKm2(outer); if (ar > bestArea) { bestArea = ar; bestRing = outer; } } this.lonInt = { a: lonA, b: lonB, full: lonFull }; this.latMin = latMin; this.latMax = latMax; // area-weighted centroid of the largest outer ring, in unwrapped space const { lons } = unwrapRing(bestRing); const lats = bestRing.map((p) => p[1]); const n = bestRing.length; let A = 0; let cx = 0; let cy = 0; for (let i = 0; i < n; i++) { const x1 = lons[i]; const y1 = lats[i]; const x2 = lons[(i + 1) % n]; const y2 = lats[(i + 1) % n]; const f = x1 * y2 - x2 * y1; A += f; cx += (x1 + x2) * f; cy += (y1 + y2) * f; } A /= 2; if (Math.abs(A) > 1e-9) { cx /= 6 * A; cy /= 6 * A; } else { cx = (lonA + lonB) / 2; cy = (latMin + latMax) / 2; } this.clon = (((cx + 180) % 360) + 360) % 360 - 180; this.clat = cy; } /** Cyclic planar distance (km) to another region's centroid. */ distKm(o) { let dlon = Math.abs(this.clon - o.clon) % 360; if (dlon > 180) dlon = 360 - dlon; const midLat = rad((this.clat + o.clat) / 2); return Math.hypot(dlon * KM_LON * Math.cos(midLat), (this.clat - o.clat) * KM_LAT); } /** Bounding-box closeness to another same-space region (see CLOSE_DEG). */ closeTo(o) { const latGap = this.latMax < o.latMin ? o.latMin - this.latMax : o.latMax < this.latMin ? this.latMin - o.latMax : 0; if (latGap > CLOSE_DEG) return false; return lonGap(this.lonInt, o.lonInt) <= CLOSE_DEG; } } /** * A "degenerate" region is a zero-area artifact: after 4-dp rounding its only * ring has <3 distinct points (a line/point, e.g. a micro-municipality that * v0.13's Douglas–Peucker collapsed to `[A, B, A]`). It contributes no area, * so it can never be dissolved by the clipper (which returns null) nor kept * through ring-dropping (which loses the 2-point ring). Merging one is purely * an absorption: the host keeps its own geometry. */ const isDegenerate = (r) => r.area < SLIVER_EPS_KM2; // --- main ---------------------------------------------------------------------- function main() { const fc0 = JSON.parse(readFileSync(FILE, 'utf8')); const regions = fc0.features.map((f) => new Region(f)); const byParent = new Map(); for (const r of regions) { if (!byParent.has(r.parent)) byParent.set(r.parent, []); byParent.get(r.parent).push(r); } const t0 = Date.now(); console.log(`input: ${fc0.features.length} regions across ${byParent.size} countries (${(Buffer.byteLength(readFileSync(FILE)) / 1024).toFixed(0)} KB)`); console.log(`parameters: floor ${AREA_FLOOR_KM2.toLocaleString('en-US')} km2, close ${CLOSE_DEG} deg, max ${MAX_ITER} passes`); const merges = []; // { from, fromName, fromArea, to, toName, toAreaAfter } let fallbackUnions = 0; // The fixed-point loop. Each pass: every region that is small AND has a // close same-country neighbor merges into the nearest such neighbor // (nearest by centroid distance; tie → larger area → lexicographic id). for (let pass = 0; pass < MAX_ITER; pass++) { let skippedIsland = 0; let skippedGrew = 0; const items = []; for (const r of regions) { if (r.dead || r.area >= AREA_FLOOR_KM2) continue; const siblings = byParent.get(r.parent); for (const s of siblings) { if (s !== r && !s.dead && r.closeTo(s)) { items.push(r); break; } } } if (!items.length) { console.log(`after pass ${pass}: fixed point reached (no small non-island regions left)`); break; } items.sort((a, b) => (a.area !== b.area ? a.area - b.area : a.id < b.id ? -1 : 1)); let merged = 0; for (const r of items) { if (r.dead) continue; if (r.area >= AREA_FLOOR_KM2) { skippedGrew++; continue; // absorbed a smaller region earlier this pass and crossed the floor } const siblings = byParent.get(r.parent); // Close same-country neighbors as of NOW (mid-pass): some may have died // earlier in this pass. const close = []; for (const s of siblings) if (s !== r && !s.dead && r.closeTo(s)) close.push(s); if (!close.length) { skippedIsland++; continue; // lost its last close neighbor this pass → island now → exempt } // Prefer a REAL (non-degenerate) host so real regions dissolve cleanly; // fall back to any close neighbor only when no real one is nearby. const realCands = close.filter((s) => !isDegenerate(s)); const pool = realCands.length ? realCands : close; let host = null; for (const s of pool) { if ( !host || r.distKm(s) < r.distKm(host) || (r.distKm(s) === r.distKm(host) && (s.area !== host.area ? s.area > host.area : s.id < host.id)) ) host = s; } if (isDegenerate(r)) { // r is a zero-area artifact (line/point): it contributes no area, so the // merge is a pure absorption — the host keeps its own geometry (a real // host is untouched; if the host is also degenerate a cluster of dots // collapses to one). Never route degenerate input through the clipper // (returns null) or ring-dropping (would lose the 2-point ring the map // already renders). r.dead = true; merged++; merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area }); continue; } if (isDegenerate(host)) { // r is real but the chosen host is a zero-area artifact: the host takes // on r's geometry (keeping its own id/name). No clipper needed. host.geom = r.geom; host.rawGeom = r.geom; host.metrics(); r.dead = true; merged++; merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area }); continue; } // Both real: dissolve via a boolean union; the host keeps its id/name // and the item vanishes. @turf/union@7 dispatches through geomEach, which // only understands a FeatureCollection / single Feature / bare geometry — // a raw array of features has no `.type` and throws "Unknown Geometry // Type". Wrap it. let unionGeom = null; let rawUnion = null; try { const u = union(featureCollection([feature(r.geom), feature(host.geom)])); rawUnion = u ? u.geometry : null; unionGeom = normalizeGeom(rawUnion); } catch (err) { console.error(` [warn] union failed for ${r.id} into ${host.id} (${err.message}); combining without dissolve`); fallbackUnions++; } if (!unionGeom) { // fallback: no dissolve — keep both shapes in one MultiPolygon const polys = [...(r.geom.type === 'Polygon' ? [r.geom.coordinates] : r.geom.coordinates), ...(host.geom.type === 'Polygon' ? [host.geom.coordinates] : host.geom.coordinates)]; rawUnion = { type: 'MultiPolygon', coordinates: polys }; unionGeom = normalizeGeom(rawUnion); if (!unionGeom) fallbackUnions++; } if (!unionGeom) { console.error(` [error] could not merge ${r.id} into ${host.id}; leaving ${r.id} as-is`); continue; } host.geom = unionGeom; host.rawGeom = rawUnion; host.metrics(); r.dead = true; merged++; merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area }); } const alive = regions.filter((r) => !r.dead).length; console.log( `pass ${pass + 1}: ${items.length} small non-island region(s) → ${merged} merged` + ` (${skippedGrew} crossed the floor, ${skippedIsland} became island) → ${alive} regions remain`, ); if (merged === 0) { // items exist but nothing could merge (all became islands this pass) → stable console.log(`pass ${pass + 1}: no merges possible — stable state`); break; } } // --- assemble output ---------------------------------------------------------- const alive = regions.filter((r) => !r.dead); const sortById = (a, b) => a.parent !== b.parent ? (a.parent < b.parent ? -1 : 1) : a.id.localeCompare(b.id, 'en', { numeric: true }); alive.sort(sortById); const build = (tol) => { const feats = []; for (const r of alive) { // Never drop a region: if the simplified/normalized geometry degenerated, // fall back to the raw (pre-normalization) union output, then to the // untouched input geometry. const g = transformGeom(r.geom, tol) ?? (r.rawGeom ? transformGeom(r.rawGeom, 0) ?? r.rawGeom : r.geom); feats.push({ type: 'Feature', id: r.id, properties: { name: r.name, parent: r.parent }, geometry: g }); } return { type: 'FeatureCollection', features: feats }; }; let out = build(0); let json = JSON.stringify(out); let sizeKB = Buffer.byteLength(json) / 1024; console.log(`\ncoarsened: ${fc0.features.length} → ${out.features.length} regions (${fc0.features.length - out.features.length} merged away)`); let usedTol = 0; if (sizeKB > SIZE_GUARD_KB) { for (const t of DP_STEPS) { out = build(t); json = JSON.stringify(out); sizeKB = Buffer.byteLength(json) / 1024; usedTol = t; console.log(`DP ${t}: ${sizeKB.toFixed(1)} KB`); if (sizeKB < SIZE_TARGET_KB) break; } } writeFileSync(FILE, json + '\n'); // --- summary ------------------------------------------------------------------- const areaOf = new Map(); const counts = new Map(); let maxA = 0; let maxR = null; for (const f of out.features) { const a = geomAreaKm2(f.geometry); areaOf.set(f.id, a); counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1); if (a > maxA) { maxA = a; maxR = f; } } const ccounts = [...counts.values()].sort((a, b) => a - b); const median = ccounts.length % 2 ? ccounts[(ccounts.length - 1) / 2] : (ccounts[ccounts.length / 2 - 1] + ccounts[ccounts.length / 2]) / 2; const maxCountry = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]; // islands = small regions with no close same-country neighbor in the FINAL set const finalByParent = new Map(); for (const f of out.features) { if (!finalByParent.has(f.properties.parent)) finalByParent.set(f.properties.parent, []); finalByParent.get(f.properties.parent).push(f); } // rebuild final metrics (light: only need island flag on the < FLOOR set) const finalRegions = out.features.map((f) => new Region(f)); const finalByParent2 = new Map(); for (const r of finalRegions) { if (!finalByParent2.has(r.parent)) finalByParent2.set(r.parent, []); finalByParent2.get(r.parent).push(r); } let smallIslands = 0; let smallNonIslands = 0; const islandSamples = []; for (const r of finalRegions) { if (r.area >= AREA_FLOOR_KM2) continue; let hasNeighbor = false; for (const s of finalByParent2.get(r.parent)) { if (s !== r && r.closeTo(s)) { hasNeighbor = true; break; } } if (hasNeighbor) smallNonIslands++; else { smallIslands++; if (islandSamples.length < 12) islandSamples.push(`${r.id} ${r.name} (${Math.round(r.area)} km2)`); } } console.log(`wrote ${FILE} — ${sizeKB.toFixed(1)} KB (${(sizeKB / 1024).toFixed(2)} MB)` + (usedTol ? ` [DP @ ${usedTol}°]` : '')); console.log(`per-country count: min ${ccounts[0]} / median ${median} / max ${ccounts[ccounts.length - 1]} (max: ${maxCountry[0]})`); console.log(`max region: ${maxR.id} (${maxR.properties.name}) ≈ ${Math.round(maxA).toLocaleString('en-US')} km2`); console.log(`small (<${AREA_FLOOR_KM2.toLocaleString('en-US')} km2) in final set: ${smallIslands + smallNonIslands} — ${smallIslands} islands (exempt) / ${smallNonIslands} non-island residuals`); if (islandSamples.length) console.log(`island samples: ${islandSamples.join(', ')}`); console.log(`fallback (non-dissolved) unions: ${fallbackUnions}`); console.log(`merge log (${merges.length} merges): first 15:`); for (const m of merges.slice(0, 15)) { console.log(` ${m.from} (${m.fromName}, ${Math.round(m.fromArea)} km2) → ${m.to} (${m.toName}, now ${Math.round(m.toAreaAfter)} km2)`); } const logPath = '/tmp/opencode/coarsen-log.txt'; writeFileSync(logPath, merges.map((m) => `${m.from}\t${m.fromName}\t${Math.round(m.fromArea)}\t${m.to}\t${m.toName}\t${Math.round(m.toAreaAfter)}`).join('\n') + '\n'); console.log(`full merge log → ${logPath}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`); } main();