Fill no-one's-land gaps between merged sub-regions (v0.17.0)
CI / build + unit + e2e (pull_request) Successful in 2m19s
CI / build + unit + e2e (pull_request) Successful in 2m19s
Part of #16
This commit is contained in:
+178
-51
@@ -12,9 +12,16 @@
|
||||
* Tiny or uncovered countries collapse to a single whole-country region
|
||||
* (`<ISO_A3>-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:
|
||||
* 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)
|
||||
*
|
||||
@@ -22,6 +29,11 @@
|
||||
* 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';
|
||||
@@ -77,6 +89,50 @@ function median(nums: number[]): number {
|
||||
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 {
|
||||
@@ -112,6 +168,9 @@ for (const f of regions.features)
|
||||
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) ------
|
||||
|
||||
@@ -326,56 +385,54 @@ describe('regions.geo.json', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
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++) {
|
||||
const hole = poly[i];
|
||||
holes++;
|
||||
let lat = 0;
|
||||
for (const [, la] of hole) lat += la;
|
||||
const area = ringAreaKm2(hole, hole.length ? lat / hole.length : 0);
|
||||
holeCount++;
|
||||
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;
|
||||
if (area < HOLE_MIN_KM2) {
|
||||
smallHoles++;
|
||||
if (smallHoles <= 10) console.log(` small hole ${f.id} ≈ ${Math.round(area)} km2`);
|
||||
}
|
||||
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(
|
||||
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`,
|
||||
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 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`,
|
||||
`\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%, 175%] of each country area (median in [70%, 130%])', () => {
|
||||
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), 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.
|
||||
// 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;
|
||||
@@ -389,8 +446,8 @@ describe('regions.geo.json', () => {
|
||||
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%]`,
|
||||
ratio >= 0.4 && ratio <= 1.9,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 190%]`,
|
||||
).toBe(true);
|
||||
}
|
||||
const medRatio = median(ratios);
|
||||
@@ -401,18 +458,88 @@ describe('regions.geo.json', () => {
|
||||
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%)`,
|
||||
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–190%)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('is at the Douglas–Peucker 50m fixed point: 1,796 regions, topology unchanged, 111,175 vertices (v0.16 smoothing)', () => {
|
||||
// v0.16 (scripts/smooth-regions.mjs) thinned every ring with Douglas–Peucker at
|
||||
// ~50 m. That removes redundant vertices only — it never moves a border, merges
|
||||
// a part, or creates/removes a ring — so the topology signature is invariant
|
||||
// (1,796 features, 5,026 parts, 5,027 rings, the single KZ-KZY hole) and the
|
||||
// vertex total sits at the pass's fixed point (re-running changes nothing).
|
||||
// Pinning it guards against an accidental re-coarsening or a different-tolerance
|
||||
// re-smooth silently changing the shipped dataset.
|
||||
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,
|
||||
@@ -429,15 +556,15 @@ describe('regions.geo.json', () => {
|
||||
for (const ring of poly) verts += ring.length;
|
||||
}
|
||||
}
|
||||
expect(regions.features.length, 'region count').toBe(1796);
|
||||
expect(verts, 'total vertices (incl. ring closure)').toBe(111_175); // was 111,891 pre-v0.16; 716 removed
|
||||
expect(parts, 'parts').toBe(5026); // unchanged — no part merged/dropped
|
||||
expect(rings, 'rings').toBe(5027); // unchanged — no ring added/dropped
|
||||
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.16 smoothing: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
||||
`${holes} hole (KZ-KZY), ${verts} vertices (50m DP fixed point; 716 removed from 111,891) — topology unchanged`,
|
||||
`\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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user