571 lines
25 KiB
TypeScript
571 lines
25 KiB
TypeScript
/**
|
||
* 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.17 fixed point: 1,796 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 region count (1,796), the
|
||
// per-feature ids/names/parents and their order are unchanged; only the
|
||
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
||
// the signature guards against a non-idempotent or destructive re-run.
|
||
// The fill is byte-deterministic per input and idempotent (a second run
|
||
// on the filled file 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(1796); // unchanged by the fill
|
||
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
|
||
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
|
||
console.log(
|
||
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
||
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
||
);
|
||
});
|
||
});
|