#!/usr/bin/env node /** * scripts/split-arkhangel.mjs — split the RU-ARK ("Arkhangel'sk") mega-region * into main + two detached island archipelagos (issue #31). * * `RU-ARK` is a 78-part MultiPolygon of ~411,500 km²: the Arkhangel'sk * mainland plus Arctic-coast mainland parts, tiny islets, AND two far-north * island groups that belong nowhere near the mainland — the Franz Josef Land * islets (New Earth, centroid lat ≥ 79.5) and the Severnaya Zemlya islets * (centroid lat 76–79.5, lon ≥ 55). This stage repartitions the existing * polygon parts between three features so each island group is its own * addressable sub-region: * * RU-ARK (keeps its id and name) — every part whose centroid has * lat < 76 (the mainland, Arctic-coast * mainland parts, islets, one south dot) * RU-ARK-NE "New Earth" — every part whose centroid has * lat ≥ 79.5 (the 42 Franz Josef Land * islets, ~15,700 km²) * RU-ARK-SZ "Severnaya Zemlya" — every part whose centroid has * 76 ≤ lat < 79.5 AND lon ≥ 55 (the 3 * Severnaya Zemlya islets, ~1,200 km²) * * (Parts in the 76–79.5° band with lon < 55 would stay in RU-ARK; none exist * in the current dataset — the split is verified clean before it is written.) * * PURE REPARTITION — no geometry is created, moved, or altered: each part is * moved verbatim (same coordinates, same ring order) from the one feature to * the other. The union of the three output geometries is byte-identical to * the input feature's parts, so no border moves and no new overlap is * possible. RU-ARK keeps its stable feature id, so its region key * (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing * `Nation.territory` / SQLite ownership referencing it stays valid; the two * new ids (`RU-ARK-NE`, `RU-ARK-SZ`) get their own keys derived by the same * FNV-1a rule in `scripts/build-region-keys.mjs` (run it after this stage). * * The new features are inserted immediately after RU-ARK (stable file order, * deterministic bytes). Each new feature's `properties` copies RU-ARK's * (only `name` and `parent` exist in this dataset) with `name` overridden; * `parent` stays RUS. Each feature's geometry is a MultiPolygon of its * assigned parts, or a single Polygon if it ends up with one part. * * Pipeline position: between `fill-gaps` and `build-region-keys` (the * manifests must be rebuilt after any geo change): * * generate → coarsen → cleanup → smooth → fill-gaps → **split-arkhangel** * → build-region-keys → build-region-adjacency * * Idempotent: when RU-ARK-NE and RU-ARK-SZ already exist the file is left * untouched (no write at all), so a full re-run of the pipeline reproduces * byte-identical output. * * Usage: node scripts/split-arkhangel.mjs [inFile [outFile]] * (or `npm run split:regions`) * inFile default: public/regions.geo.json * outFile default: inFile (edit in place) * * `@turf/centroid` + `@turf/helpers` are used only to classify each part by * its centroid — the same measurement the split thresholds are defined in. * (Turf is allowed in .mjs scripts only; never imported by any .ts file.) */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import centroid from '@turf/centroid'; import area from '@turf/area'; import { polygon } from '@turf/helpers'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json'); const outFile = process.argv[3] ?? inFile; // The split thresholds (degrees), per issue #31. const NEW_EARTH_MIN_LAT = 79.5; // centroid lat ≥ this → New Earth (Franz Josef Land) const SZ_BAND_MIN_LAT = 76; // centroid lat ≥ this (and < NEW_EARTH_MIN_LAT)… const SZ_MIN_LON = 55; // …and centroid lon ≥ this → Severnaya Zemlya // (anything else — including the 76–79.5° band west of 55°E, of which the // current dataset has none — stays in the main Arkhangel'sk feature) /** A GeoJSON polygon part as a turf Feature (for centroid/area only). */ const partFeature = (part) => polygon(part); /** * A part's centroid [lon, lat]. Strict by design: a centroid that cannot be * computed is a data error, not something to silently route to the mainland. */ function centroidOf(part) { try { return centroid(partFeature(part)).geometry.coordinates; } catch (err) { throw new Error(`cannot compute centroid for a RU-ARK part: ${err.message}`); } } /** Classify a centroid [lon, lat] into one of the three output buckets. */ function bucketOf([lon, lat]) { if (lat >= NEW_EARTH_MIN_LAT) return 'ne'; if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon >= SZ_MIN_LON) return 'sz'; return 'main'; } // --- load -------------------------------------------------------------------- const geo = JSON.parse(readFileSync(inFile, 'utf8')); if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) { throw new Error(`${inFile} is not a FeatureCollection`); } const features = geo.features; // --- idempotency: already split? ---------------------------------------------- const ids = new Set(features.map((f) => String(f.id ?? ''))); if (ids.has('RU-ARK-NE') && ids.has('RU-ARK-SZ')) { console.log( `[split-arkhangel] RU-ARK-NE and RU-ARK-SZ already present — no-op (idempotent), ${inFile} left untouched`, ); process.exit(0); } const idx = features.findIndex((f) => f.id === 'RU-ARK'); if (idx < 0) { throw new Error('RU-ARK not found in ' + inFile); } const ark = features[idx]; if (ark.geometry?.type !== 'MultiPolygon') { throw new Error( `RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the split expects the pre-#31 mega-region)`, ); } const parts = ark.geometry.coordinates; // --- partition (stable: original part order is kept inside each bucket) ------- const buckets = { main: [], ne: [], sz: [] }; let bandWest = 0; // 76–79.5°-band parts west of 55°E (stay in main; 0 in the current data) for (const part of parts) { const [lon, lat] = centroidOf(part); if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon < SZ_MIN_LON) bandWest += 1; buckets[bucketOf([lon, lat])].push(part); } // --- rebuild geometries (parts verbatim; MultiPolygon, or Polygon if 1 part) -- const geom = (ps) => ps.length > 1 ? { type: 'MultiPolygon', coordinates: ps } : { type: 'Polygon', coordinates: ps[0] }; ark.geometry = geom(buckets.main); const mkFeature = (id, name, ps) => ({ type: 'Feature', id, // copy every property RU-ARK has (currently just { name, parent }), overriding name properties: { ...ark.properties, name }, geometry: geom(ps), }); const ne = mkFeature('RU-ARK-NE', 'New Earth', buckets.ne); const sz = mkFeature('RU-ARK-SZ', 'Severnaya Zemlya', buckets.sz); // --- report ------------------------------------------------------------------- const km2 = (ps) => ps.reduce((s, p) => s + area(partFeature(p)) * 1e-6, 0); console.log(`[split-arkhangel] input: ${inFile}`); console.log( ` RU-ARK (main) ${buckets.main.length} parts (of ${parts.length}), ≈ ${Math.round(km2(buckets.main)).toLocaleString('en-US')} km²` + (bandWest ? ` — incl. ${bandWest} part(s) in the 76–79.5° band west of 55°E (kept in main)` : ''), ); console.log( ` RU-ARK-NE (New Earth) ${buckets.ne.length} parts, ≈ ${Math.round(km2(buckets.ne)).toLocaleString('en-US')} km²`, ); console.log( ` RU-ARK-SZ (Severnaya Zemlya) ${buckets.sz.length} parts, ≈ ${Math.round(km2(buckets.sz)).toLocaleString('en-US')} km²`, ); if (buckets.ne.length === 0 || buckets.sz.length === 0) { throw new Error( `threshold mis-split: New Earth=${buckets.ne.length} parts, Severnaya Zemlya=${buckets.sz.length} parts — check the centroids before shipping`, ); } // --- insert at a stable position (immediately after RU-ARK) and write --------- features.splice(idx + 1, 0, ne, sz); writeFileSync(outFile, JSON.stringify(geo)); console.log(`[split-arkhangel] wrote ${outFile} — features ${features.length - 2} → ${features.length}`);