Files
History/scripts/regions.test.ts
T
kamilkrzywda f6ad554823
CI / build + unit + e2e (pull_request) Successful in 2m34s
Re-coarsen RU-ARK Arctic-coast band into RU-PEO + RU-YAM (#41)
#41: re-partition the RU-ARK mainland's 28-part lat>=68 Arctic-coast band
into two new regions via a new turf-free, idempotent pipeline stage
scripts/split-arkhangel-band.mjs (npm run split:regions:band), mirroring the
#31 split:

- RU-PEO (Pechora, 13 parts, ~37,678 km2) -> key pechora-qwhs
- RU-YAM (Yamal, 15 parts, ~51,722 km2) -> key yamal-gosw
- RU-ARK keeps its id/name/key arkhangel-sk-lhbk (ownership-safe); the 5-part
  lat<68 core (~305,229 km2) stays RU-ARK.

Pure re-partition: no geometry moved (byte-identical union). The two new
features are inserted immediately after RU-ARK-SZ. Manifests regenerated with
the real scripts (1,798 -> 1,800 keys + adjacency; adjacency pairs 4,471 ->
4,473). Fixed-point dataset unchanged except feature count (11,794 parts /
11,795 rings / 164,801 verts / 1 KZ-KZY hole). Four dataset/manifest tests
updated (RUS 82 -> 84 sub-regions). Version 0.25.0 -> 0.26.0.

Fixes #41
2026-10-14 19:26:49 +00:00

629 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
*
* The dataset is REAL named sub-national administrative regions (Natural
* Earth admin-1: states, provinces, governorates, oblasts, …) that the
* coarsening step (`scripts/coarsen-regions.mjs`, v0.14) has merged down to a
* ~100×100 km (10,000 km²) floor: every small non-island region was dissolved
* into its nearest same-country neighbor, so the only regions left below the
* floor are islands (no same-country sibling nearby). One feature per named
* region, each carrying `properties = { name, parent }` where `parent` is the
* country's ISO_A3 (the RegionMap key the map resolves ownership through).
* Tiny or uncovered countries collapse to a single whole-country region
* (`<ISO_A3>-01`).
*
* v0.17 (`scripts/fill-gaps.mjs`) additionally fills the no-one's-land left
* between merged sub-regions: per country, the uncovered slivers of the
* country's landmass (≥ 25 km²) were added as extra parts of the nearest
* region, so the regions now cover the landmass almost completely (the
* sampling test below measures that per country).
*
* This test is self-contained (no import of the generator) and deliberately
* dependency-free — it re-implements the same planar area model the
* generator/analysis use, so the area-based assertions measure the same
* quantity:
*
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
*
* with a shoelace per OUTER ring, each ring measured at its own mean latitude.
* Outer rings only — a region's inner rings (holes) are lakes and are ignored
* by design, the same way the coverage band already absorbed them.
*
* No geojson/@turf types are imported: the project keeps `@types/geojson`
* OUT of the tsc program (see tsconfig `types`) so maplibre-gl's d.ts stays
* in its loose (skipLibCheck) mode; the one coverage test below uses an
* inline even-odd ray cast instead of a geometry library.
*
* It reads each file exactly once and runs in O(total vertices).
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
// --- area model (planar approximation; matches scripts/generate-regions.mjs) --
const KM_LON = 111.32;
const KM_LAT = 110.574;
const rad = (deg: number) => (deg * Math.PI) / 180;
type Ring = [number, number][];
type Geometry =
| { type: 'Polygon'; coordinates: Ring[] }
| { type: 'MultiPolygon'; coordinates: Ring[][] };
function ringAreaKm2(ring: Ring, midLat: number): number {
const kx = KM_LON * Math.cos(rad(midLat));
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);
}
function outerRings(geom: Geometry): Ring[] {
if (geom.type === 'Polygon') return [geom.coordinates[0]];
if (geom.type === 'MultiPolygon') return geom.coordinates.map((p) => p[0]);
return [];
}
/** Sum of a feature's outer-ring areas, each ring at its own mean latitude. */
function featureAreaKm2(geom: Geometry): number {
let total = 0;
for (const ring of outerRings(geom)) {
let lat = 0;
for (const [, la] of ring) lat += la;
total += ringAreaKm2(ring, ring.length ? lat / ring.length : 0);
}
return total;
}
function median(nums: number[]): number {
if (!nums.length) return 0;
const s = [...nums].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
/** Raw min/max bbox [w, s, e, n] of a geometry (all coordinates). */
function rawBBox(geom: Geometry): [number, number, number, number] {
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
for (const poly of polys)
for (const ring of poly)
for (const [lo, la] of ring) {
if (lo < w) w = lo;
if (lo > e) e = lo;
if (la < s) s = la;
if (la > n) n = la;
}
return [w, s, e, n];
}
/**
* Even-odd ray cast: is `[lon, lat]` inside `ring`? Planar, in raw lon/lat
* space (no antimeridian handling — the polygons and the sample points live
* in the same unwrapped space, so containment is mathematically consistent
* even for countries that cross the date line). Used only as a fast,
* deterministic coverage proxy (see the sampling test), never for areas.
*/
function pointInRing(pt: [number, number], ring: Ring): boolean {
const [x, y] = pt;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** Is a point inside a (Multi)Polygon: inside the outer ring, outside all holes. */
function pointInGeom(pt: [number, number], geom: Geometry): boolean {
if (geom.type === 'Polygon') {
if (!pointInRing(pt, geom.coordinates[0])) return false;
return geom.coordinates.slice(1).every((h) => !pointInRing(pt, h));
}
if (geom.type === 'MultiPolygon')
return geom.coordinates.some((p) => pointInGeom(pt, { type: 'Polygon', coordinates: p }));
return false;
}
// ------------------------------------------------------------------------------
interface RegionFeature {
type: 'Feature';
id: string;
properties: { name: string; parent: string };
geometry: Geometry;
}
interface CountryFeature {
type: 'Feature';
id: string;
properties: { name: string };
geometry: Geometry;
}
// Read each file exactly once.
const regions = JSON.parse(
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
) as { type: 'FeatureCollection'; features: RegionFeature[] };
const countries = JSON.parse(
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
) as { type: 'FeatureCollection'; features: CountryFeature[] };
// Precompute once (O(total vertices)).
const countryIds = new Set(countries.features.map((c) => c.id));
const countryArea = new Map<string, number>();
for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry));
const regionArea = new Map<string, number>();
for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry));
const sumByParent = new Map<string, number>();
for (const f of regions.features)
sumByParent.set(f.properties.parent, (sumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0));
const countByParent = new Map<string, number>();
for (const f of regions.features)
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
// Raw bboxes for the sampling test's cheap reject (O(total vertices)).
const regionBB = new Map<string, [number, number, number, number]>();
for (const f of regions.features) regionBB.set(f.id, rawBBox(f.geometry));
// --- island/close-neighbor helpers (mirror scripts/coarsen-regions.mjs) ------
interface LonInt {
a: number;
b: number;
full: boolean;
}
interface RegionBBox {
latMin: number;
latMax: number;
lon: LonInt;
}
const COARSE_FLOOR_KM2 = 10_000; // the coarsener's ~100×100 km floor
const CLOSE_DEG = 0.2; // "spatially close" bbox gap (both lat and lon ≤ this)
/**
* Unwrap a ring's longitudes to a continuous line (np.unwrap shortest-diff
* scheme, anchored at the first vertex's [0,360) value). `full` when the
* unwrapped span is ≥ 300° (a full-360° antimeridian band, e.g. Antarctica).
* Must stay in sync with the coarsening script.
*/
function unwrapRing(ring: Ring) {
const n = ring.length;
const norm = (x: number) => ((x % 360) + 360) % 360;
const u: number[] = 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); normalize it
// before measuring the shortest signed difference, or a bogus ~180° jump
// is inserted and the ring's span balloons.
const a = norm(u[i - 1]);
const b = norm(ring[i][0]);
const d = (((b - a) % 360) + 540) % 360 - 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 spaces; a `full` interval is close to everything.
*/
function lonGap(intA: LonInt, intB: LonInt): number {
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;
}
// Per-region bbox in antimeridian-safe unwrapped longitude space.
const regionBBox = new Map<string, RegionBBox>();
for (const f of regions.features) {
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
let latMin = Infinity;
let latMax = -Infinity;
let a = Infinity;
let b = -Infinity;
let full = false;
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: fl } = unwrapRing(outer);
if (fl) full = true;
for (const v of lons) {
if (v < a) a = v;
if (v > b) b = v;
}
}
regionBBox.set(f.id, { latMin, latMax, lon: { a, b, full } });
}
/** Bounding-box closeness (same definition the coarsening script uses). */
function bboxesClose(x: RegionBBox, y: RegionBBox): boolean {
const latGap = x.latMax < y.latMin ? y.latMin - x.latMax : y.latMax < x.latMin ? x.latMin - y.latMax : 0;
if (latGap > CLOSE_DEG) return false;
return lonGap(x.lon, y.lon) <= CLOSE_DEG;
}
describe('regions.geo.json', () => {
it('is a valid GeoJSON FeatureCollection of well-formed features', () => {
expect(regions.type).toBe('FeatureCollection');
expect(Array.isArray(regions.features)).toBe(true);
expect(regions.features.length).toBeGreaterThan(0);
for (const f of regions.features) {
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
expect(typeof f.id, `${f.id ?? '?'}: id must be a string`).toBe('string');
expect(f.id.length, `${f.id ?? '?'}: empty id`).toBeGreaterThan(0);
expect(typeof f.properties?.name, `${f.id}: missing name`).toBe('string');
expect(f.properties.name.length, `${f.id}: empty name`).toBeGreaterThan(0);
expect(typeof f.properties.parent, `${f.id}: missing parent`).toBe('string');
expect(countryIds.has(f.properties.parent), `${f.id}: unknown parent ${f.properties.parent}`).toBe(true);
const g = f.geometry;
expect(
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
`${f.id}: bad geometry type ${g?.type}`,
).toBe(true);
if (g) {
const polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates;
for (const poly of polys)
for (const ring of poly)
expect(ring.length, `${f.id}: ring with ${ring.length} pts`).toBeGreaterThanOrEqual(3);
}
}
});
it('has globally unique feature ids (ISO 3166-2 disambiguated on within-country collision)', () => {
const seen = new Set<string>();
const dups = new Set<string>();
for (const f of regions.features) {
if (seen.has(f.id)) dups.add(f.id);
seen.add(f.id);
}
expect(
dups.size,
`duplicate ids: ${[...dups].slice(0, 10).join(', ')}`,
).toBe(0);
expect(seen.size).toBe(regions.features.length);
});
it('covers every one of the 177 countries with at least one region', () => {
const counts = countries.features.map((c) => countByParent.get(c.id) ?? 0);
for (const c of countries.features) {
expect(countByParent.get(c.id) ?? 0, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
}
const min = Math.min(...counts);
const max = Math.max(...counts);
const med = median(counts);
const maxCountry = [...countByParent.entries()].sort((a, b) => b[1] - a[1])[0];
const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? '';
console.log(
`\n per-country region count: min ${min} / median ${med} / max ${max}` +
` (max: ${maxCountry[0]} ${maxCountryName} with ${maxCountry[1]})`,
);
});
it('has sane region areas: median >= 100 km2, and no non-Antarctica region >= 5,000,000 km2', () => {
const areas = regions.features.map((f) => regionArea.get(f.id) ?? 0);
const med = median(areas);
const maxRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) > (regionArea.get(m.id) ?? 0) ? f : m));
const minRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) < (regionArea.get(m.id) ?? 0) ? f : m));
const tiny = areas.filter((a) => a < 1).length;
// Antarctica (ATA) is a full-360° wrap ring, so the planar model can't
// measure it (it reports ~21.8M km2 for a ~14M km2 landmass) — exempt it.
for (const f of regions.features) {
if (f.properties.parent === 'ATA') continue;
const a = regionArea.get(f.id) ?? 0;
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km2 is absurdly large`).toBe(true);
}
expect(med, `median region area ${med.toFixed(1)} km2 is too small`).toBeGreaterThanOrEqual(100);
console.log(
`\n region area: min ${minRegion.id} (${minRegion.properties.name}) ≈ ${(regionArea.get(minRegion.id) ?? 0).toExponential(2)} km2` +
` / median ${med.toLocaleString('en-US')} km2` +
` / max ${maxRegion.id} (${maxRegion.properties.name}) ≈ ${Math.round(regionArea.get(maxRegion.id) ?? 0).toLocaleString('en-US')} km2` +
` — ${tiny} regions < 1 km2 (legit micro-regions: tiny islands, small urban districts, degenerate disputed enclaves)`,
);
});
it('leaves only islands below the ~10,000 km2 coarsening floor (non-island small regions merged)', () => {
// v0.14 coarsening: every region smaller than the floor that has a
// same-country sibling within CLOSE_DEG of its bbox is dissolved into its
// nearest such neighbor, so a below-floor region may only survive if it
// is an island. Count the non-island residuals below the floor and allow
// a small tolerance band (strictly < 1% of all regions) — the coarsener's
// fixed point leaves none, and this catches a regression in the merge
// logic (e.g. a broken neighbor test that exempts land regions) without
// being brittle to one or two borderline cases.
const smallNonIslands: string[] = [];
let smallIslands = 0;
let smallTotal = 0;
for (const f of regions.features) {
const a = regionArea.get(f.id) ?? 0;
if (a >= COARSE_FLOOR_KM2) continue;
smallTotal++;
const bb = regionBBox.get(f.id)!;
let hasNeighbor = false;
for (const g of regions.features) {
if (g.id === f.id || g.properties.parent !== f.properties.parent) continue;
if (bboxesClose(bb, regionBBox.get(g.id)!)) {
hasNeighbor = true;
break;
}
}
if (hasNeighbor) {
smallNonIslands.push(`${f.id} (${Math.round(a)} km2)`);
} else smallIslands++;
}
const frac = regions.features.length ? smallNonIslands.length / regions.features.length : 0;
expect(
frac < 0.01,
`${smallNonIslands.length} non-island region(s) below the ${COARSE_FLOOR_KM2} km2 floor ` +
`(${(100 * frac).toFixed(2)}% of all regions): ${smallNonIslands.slice(0, 10).join(', ')}`,
).toBe(true);
console.log(
`\n below-floor regions: ${smallTotal} — ${smallIslands} islands (exempt, no same-country neighbor within ${CLOSE_DEG}°)` +
` / ${smallNonIslands.length} non-island residuals (must stay < 1% of all regions)`,
);
});
it('leaves exactly one enclosed hole: KZ-KZY (the real Aral Sea), still large', () => {
// v0.15 filled every small hole; v0.16 smoothing never touches holes; and
// v0.17's gap-filler explicitly keeps the Aral hole (its centroid matches a
// pre-existing hole and it lies inside another country's landmass) while
// dropping phantom holes. So the whole file must contain exactly one hole,
// on KZ-KZY, at a realistic size (~5,860 km2 planar; the real Aral has
// shrunk to roughly this by the 2020s) — no new holes of any size.
let holes = 0;
let maxHole = 0;
const holeIds: string[] = [];
let kzkzyHoleArea = 0;
for (const f of regions.features) {
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
for (const poly of polys) {
for (let i = 1; i < poly.length; i++) {
holes++;
let lat = 0;
for (const [, la] of poly[i]) lat += la;
const area = ringAreaKm2(poly[i], poly[i].length ? lat / poly[i].length : 0);
if (area > maxHole) maxHole = area;
holeIds.push(f.id);
if (f.id === 'KZ-KZY') kzkzyHoleArea = area;
}
}
}
expect(holes, `expected exactly 1 hole (KZ-KZY), found ${holes}: ${[...new Set(holeIds)].join(', ')}`).toBe(1);
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
expect(
kzkzyHoleArea >= 5_000 && kzkzyHoleArea <= 7_000,
`KZ-KZY hole is ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2 — outside the 5,000–7,000 km2 Aral band`,
).toBe(true);
console.log(
`\n holes: exactly 1 (KZ-KZY ≈ ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2) — the Aral hole survived v0.17's fill`,
);
});
it('sums to within [40%, 190%] of each country area (median in [70%, 130%])', () => {
// The band is a planar-approximation sanity check. It is wider than a naive
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
// independently of the admin-0 country polygons (they are NOT clipped to
// them). v0.17's gap fill removed the large UNDER-fills (the old minimum
// was Brunei at ~52% — its regions now cover the landmass, ≈ 79%), but
// the OVER-fill tail is a pre-existing admin-1 artifact: a few tiny island
// nations (Vanuatu 178.9%, Solomon Is. 133%, Bahamas 129%) have admin-1
// polygons that extend beyond their own country polygon, and the fill
// only added area *inside* the country, so it can't shrink that tail.
// The band brackets the measured [78.8%, 178.9%] with margin while still
// catching a genuinely missing or doubled-up region.
const ratios: number[] = [];
let minRatio = Infinity;
let maxRatio = -Infinity;
let minId = '';
let maxId = '';
for (const c of countries.features) {
const ca = countryArea.get(c.id) ?? 0;
if (ca <= 0) continue;
const ratio = (sumByParent.get(c.id) ?? 0) / ca;
ratios.push(ratio);
if (ratio < minRatio) { minRatio = ratio; minId = c.id; }
if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; }
expect(
ratio >= 0.4 && ratio <= 1.9,
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 190%]`,
).toBe(true);
}
const medRatio = median(ratios);
expect(
medRatio >= 0.7 && medRatio <= 1.3,
`median coverage ${(100 * medRatio).toFixed(1)}% is outside [70%, 130%]`,
).toBe(true);
console.log(
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
` / median ${(100 * medRatio).toFixed(1)}%` +
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–190%)`,
);
});
it('covers each country\'s landmass with no significant no-one\'s-land (v0.17 gap fill, grid-sampled)', () => {
// The v0.17 fill (scripts/fill-gaps.mjs) assigned every uncovered sliver of
// each country's landmass (≥ 25 km²) to its nearest region, so the residual
// no-one's-land per country should be small everywhere. Measuring the
// exact union is too slow, so this samples a 25×25 grid over each
// country's raw bbox (skipping points outside the country polygon) and
// counts the fraction of in-land sample points that fall in NONE of the
// country's regions — a fast, deterministic proxy (same planar space,
// inline even-odd ray cast; antimeridian-safe because points and polygons
// share the raw coordinate space). Sampling over-counts coastline
// mismatch (admin-0 vs admin-1 edges), so the thresholds are generous:
// they catch a real regression (a whole sliver left unfilled) without
// being brittle to digitization fuzz.
const N = 25;
const results: { id: string; name: string; frac: number; land: number }[] = [];
for (const c of countries.features) {
const [w, s, e, n] = rawBBox(c.geometry);
if (e - w < 0.01 || n - s < 0.01) continue; // degenerate (point-like) country
const regs = regions.features.filter((f) => f.properties.parent === c.id);
let land = 0;
let uncov = 0;
for (let i = 0; i < N; i++) {
const la = s + ((i + 0.5) / N) * (n - s);
for (let j = 0; j < N; j++) {
const lo = w + ((j + 0.5) / N) * (e - w);
const pt: [number, number] = [lo, la];
if (!pointInGeom(pt, c.geometry)) continue;
land++;
// cheap bbox reject first, then the full containment test
if (!regs.some((f) => {
const [rw, rs, re, rn] = regionBB.get(f.id)!;
if (lo < rw || lo > re || la < rs || la > rn) return false;
return pointInGeom(pt, f.geometry);
}))
uncov++;
}
}
if (land >= 4) results.push({ id: c.id, name: c.properties.name, frac: uncov / land, land });
}
expect(results.length, `only ${results.length} countries sampled (expected ~176)`).toBeGreaterThanOrEqual(150);
const fracs = results.map((r) => r.frac).sort((a, b) => a - b);
const med = median(fracs);
const p90 = fracs[Math.floor(fracs.length * 0.9)];
const worst = results.reduce((m, r) => (r.frac > m.frac ? r : m));
const over20 = results.filter((r) => r.frac > 0.2).sort((a, b) => b.frac - a.frac);
expect(
med <= 0.05,
`median country gap ${(100 * med).toFixed(1)}% > 5% — significant no-one's-land remains`,
).toBe(true);
expect(
p90 <= 0.15,
`p90 country gap ${(100 * p90).toFixed(1)}% > 15% — many countries have real gaps`,
).toBe(true);
expect(
worst.frac <= 0.4,
`worst country ${worst.id} (${worst.name}) gap ${(100 * worst.frac).toFixed(1)}% > 40%`,
).toBe(true);
expect(
over20.length <= 3,
`${over20.length} countries with > 20% gap: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}`,
).toBe(true);
console.log(
`\n no-one's-land (grid-sampled, N=${N}): median ${(100 * med).toFixed(1)}% / p90 ${(100 * p90).toFixed(1)}%` +
` / max ${worst.id} (${worst.name}) ${(100 * worst.frac).toFixed(1)}%` +
(over20.length ? ` — >20%: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}` : ' — no country > 20%'),
);
});
it('is at the v0.26 fixed point: 1,800 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
// fixed point: it never moves an existing border, merges a part, or
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ), and the v0.26
// band split (#41, scripts/split-arkhangel-band.mjs) repartitioned the
// 33-part mainland again into RU-ARK + RU-PEO + RU-YAM. Both are pure
// re-groupings: no part/ring/vertex moves, so only the region count
// changed (1,796 → 1,798 → 1,800); the part/ring/vertex totals, the
// per-feature ids/names/parents, and the feature order (the new features
// sit immediately after RU-ARK-SZ) are as pinned below. Pinning the
// signature guards against a non-idempotent or destructive re-run; both
// stages are byte-deterministic per input and idempotent (a second run
// changes nothing).
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
let verts = 0,
rings = 0,
parts = 0,
holes = 0;
const holeIds: string[] = [];
for (const f of regions.features) {
const polys = polysOf(f.geometry);
parts += polys.length;
for (const poly of polys) {
rings += poly.length;
holes += Math.max(0, poly.length - 1);
if (poly.length > 1) holeIds.push(f.id);
for (const ring of poly) verts += ring.length;
}
}
expect(regions.features.length, 'region count').toBe(1800); // 1,796 + the #31 islands (+2) + the #41 band regions (+2)
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the #31/#41 re-partitions
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175; unchanged by the #31/#41 re-partitions
console.log(
`\n v0.26 fixed point: ${regions.features.length} regions (1,796 + the #31 islands + the #41 band), ` +
`${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
);
});
it('has the five Arkhangel\'sk-family features: #31 islands + #41 band (RU-ARK, New Earth, Severnaya Zemlya, Pechora, Yamal)', () => {
// v0.25 (#31): the ~411,500 km² RU-ARK mega-region (a 78-part MultiPolygon
// mixing mainland and far-north islands) was repartitioned by part
// centroid into the mainland (RU-ARK, keeps its id/name/key), the Franz
// Josef Land islets (RU-ARK-NE "New Earth", centroid lat ≥ 79.5) and the
// Severnaya Zemlya islets (RU-ARK-SZ, centroid 76–79.5° N, lon ≥ 55°E).
// v0.26 (#41): the 33-part mainland (post-#31) was repartitioned again —
// the 5 lat < 68 parts stay RU-ARK (id/name/key unchanged), and the 28
// lat ≥ 68 "Arctic-coast band" parts split into RU-PEO "Pechora" (the SW
// Voronoi cell, 13 parts) and RU-YAM "Yamal" (the NE Voronoi cell, 15
// parts). Pure re-grouping: all five keep parent RUS and all 78 original
// parts of the pre-#31 feature are accounted for, verbatim.
const byId = new Map(regions.features.map((f) => [f.id, f]));
const ark = byId.get('RU-ARK');
const ne = byId.get('RU-ARK-NE');
const sz = byId.get('RU-ARK-SZ');
const peo = byId.get('RU-PEO');
const yam = byId.get('RU-YAM');
for (const f of [ark, ne, sz, peo, yam]) {
expect(f, `${f?.id ?? '?'} exists`).toBeDefined();
expect(f!.properties.parent, `${f!.id} parent`).toBe('RUS');
expect(f!.geometry.type, `${f!.id} geometry`).toBe('MultiPolygon');
}
expect(ark!.properties.name, 'RU-ARK keeps its name').toBe("Arkhangel'sk");
expect(ne!.properties.name, 'RU-ARK-NE name').toBe('New Earth');
expect(sz!.properties.name, 'RU-ARK-SZ name').toBe('Severnaya Zemlya');
expect(peo!.properties.name, 'RU-PEO name').toBe('Pechora');
expect(yam!.properties.name, 'RU-YAM name').toBe('Yamal');
// the 78 parts of the pre-split feature are all accounted for, verbatim
const countParts = (f: (typeof regions.features)[number]) =>
f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.length : 1;
expect(
countParts(ark!) + countParts(ne!) + countParts(sz!) + countParts(peo!) + countParts(yam!),
'total parts across the five features',
).toBe(78);
// the #41 split: the mainland core keeps its 5 lat < 68 parts; the band's
// 28 parts split 13 (Pechora) / 15 (Yamal)
expect(countParts(ark!), 'RU-ARK core part count').toBe(5);
expect(countParts(peo!), 'RU-PEO part count').toBe(13);
expect(countParts(yam!), 'RU-YAM part count').toBe(15);
// stable file order: RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM
const idx = regions.features.findIndex((f) => f.id === 'RU-ARK');
expect(regions.features[idx + 1]?.id).toBe('RU-ARK-NE');
expect(regions.features[idx + 2]?.id).toBe('RU-ARK-SZ');
expect(regions.features[idx + 3]?.id).toBe('RU-PEO');
expect(regions.features[idx + 4]?.id).toBe('RU-YAM');
console.log(
`\n #31 + #41: RU-ARK ${countParts(ark!)} parts (core), RU-ARK-NE ${countParts(ne!)} (New Earth), ` +
`RU-ARK-SZ ${countParts(sz!)} (Severnaya Zemlya), RU-PEO ${countParts(peo!)} (Pechora), RU-YAM ${countParts(yam!)} (Yamal)`,
);
});
});