181 lines
8.2 KiB
JavaScript
181 lines
8.2 KiB
JavaScript
#!/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}`);
|