/** * 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 * (`-01`). * * This test is self-contained (no import of the generator): 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. * * 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; } // ------------------------------------------------------------------------------ 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(); for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry)); const regionArea = new Map(); for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry)); const sumByParent = new Map(); 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(); for (const f of regions.features) countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1); // --- 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(); 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(); const dups = new Set(); 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('has no small enclosed holes left (the v0.15 cleanup filled them); only real water bodies remain', () => { // v0.15 cleanup (scripts/cleanup-regions.mjs): every enclosed hole (inner // ring) smaller than the threshold was dropped so each region is solid; // only genuine water bodies (holes >= the threshold) survive. The big real // lakes (Great Lakes, Caspian, Aral, Lake Superior, …) were already // dissolved by the v0.14 coarsening, so the surviving holes are few and // large. This guards the fix: a re-introduction of the tiny artifacts // (thousands of < threshold holes) fails here. const HOLE_MIN_KM2 = 1000; // keep in sync with cleanup-regions.mjs HOLE_AREA_MAX_KM2 let holeCount = 0; let smallHoles = 0; let maxHole = 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++) { const hole = poly[i]; let lat = 0; for (const [, la] of hole) lat += la; const area = ringAreaKm2(hole, hole.length ? lat / hole.length : 0); holeCount++; if (area > maxHole) maxHole = area; if (area < HOLE_MIN_KM2) { smallHoles++; if (smallHoles <= 10) console.log(` small hole ${f.id} ≈ ${Math.round(area)} km2`); } } } } expect( smallHoles, `${smallHoles} enclosed hole(s) below ${HOLE_MIN_KM2} km2 remain — the v0.15 cleanup should have filled them`, ).toBe(0); expect( holeCount <= 20, `${holeCount} holes remain — expected only a handful of real water bodies`, ).toBe(true); console.log( `\n enclosed holes: ${holeCount} remain (all >= ${HOLE_MIN_KM2} km2, largest ${Math.round(maxHole).toLocaleString('en-US')} km2) — small artifacts filled by the v0.15 cleanup`, ); }); it('sums to within [40%, 175%] 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), so for a handful of small/island nations the region union // over/under-fills the country: measured range is ~[52%, 151%] // (Brunei under-fills, Vanuatu over-fills). The band brackets that 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.75, `${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 175%]`, ).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–175%)`, ); }); });