Add sub-national regional dataset + generator (v0.11.0)
CI / build + unit + e2e (pull_request) Successful in 1m21s

Fixes #16
This commit is contained in:
2026-10-12 00:33:11 +00:00
parent 8252b665b5
commit d5285ce28d
8 changed files with 722 additions and 3 deletions
+191
View File
@@ -0,0 +1,191 @@
/**
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
*
* Self-contained (no import of the generator): it re-implements the generator's
* exact planar area model so the assertions measure the same quantity the
* generator used to size and clip regions:
*
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
*
* with a shoelace per outer ring, where each ring is measured at its own mean
* latitude. Outer rings only — the generator never emits holes (every ring is an
* independent piece: a separate island or a separate clipped cell fragment).
*
* 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 (must match scripts/generate-regions.mjs exactly) -----------------
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;
}
// -------------------------------------------------------------------------------
interface RegionFeature {
type: 'Feature';
id: string;
properties: { name: string; parent: string };
geometry: Geometry;
}
interface CountryFeature {
type: 'Feature';
id: string;
properties: { name: string };
geometry: Geometry;
}
interface FeatureCollection<T> {
type: 'FeatureCollection';
features: T[];
}
// Read each file exactly once.
const regions = JSON.parse(
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
) as FeatureCollection<RegionFeature>;
const countries = JSON.parse(
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
) as FeatureCollection<CountryFeature>;
// Precompute areas once (O(total vertices)).
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 regionSumByParent = new Map<string, number>();
for (const f of regions.features) {
regionSumByParent.set(
f.properties.parent,
(regionSumByParent.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);
}
describe('regions.geo.json', () => {
it('is a valid GeoJSON FeatureCollection', () => {
expect(regions.type).toBe('FeatureCollection');
expect(Array.isArray(regions.features)).toBe(true);
expect(regions.features.length).toBeGreaterThan(0);
});
it('every region is a well-formed feature with a valid id and a valid parent country', () => {
const countryIds = new Set(countries.features.map((c) => c.id));
const idRe = /^[A-Z]{3}-\d+$/;
for (const f of regions.features) {
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
expect(idRe.test(f.id), `${f.id ?? '?'}: bad id`).toBe(true);
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);
expect(f.properties.parent, `${f.id}: parent != ISO_A3 prefix`).toBe(f.id.slice(0, 3));
const g = f.geometry;
expect(
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
`${f.id}: bad geometry type ${g?.type}`,
).toBe(true);
}
});
it('gives every country 1..36 regions, and >=2 for any country > 50,000 km²', () => {
for (const c of countries.features) {
const n = countByParent.get(c.id) ?? 0;
expect(n, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
expect(n, `country ${c.id} has ${n} regions (> 36)`).toBeLessThanOrEqual(36);
if ((countryArea.get(c.id) ?? 0) > 50_000) {
expect(n, `large country ${c.id} should have >= 2 regions`).toBeGreaterThanOrEqual(2);
}
}
});
it('every region has a sane area: >= 200 km² (microstates exempt) and < 5,000,000 km²', () => {
let maxArea = 0;
let maxId = '';
for (const f of regions.features) {
const a = regionArea.get(f.id) ?? 0;
const parentArea = countryArea.get(f.properties.parent) ?? 0;
// A whole-country singleton of a sub-200 km² microstate is exempt.
expect(a >= 200 || parentArea < 200, `${f.id}: area ${a.toFixed(1)} km² is too small`).toBe(true);
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km² is absurdly large`).toBe(true);
if (a > maxArea) {
maxArea = a;
maxId = f.id;
}
}
console.log(`\n max region area: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
});
it('covers each country to within [45%, 108%] of its own area', () => {
// The band is a planar-approximation sanity check. The measured range is
// ~[64%, 105%]: Antarctica's hemispheric wedges under-fill to ~64%, and
// Norway's fjord-heavy, high-latitude coast over-fills to ~105.1% under the
// single-mid-latitude approximation. [45%, 108%] brackets that with margin
// while still catching a genuinely missing or doubled-up region.
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 = (regionSumByParent.get(c.id) ?? 0) / ca;
if (ratio < minRatio) {
minRatio = ratio;
minId = c.id;
}
if (ratio > maxRatio) {
maxRatio = ratio;
maxId = c.id;
}
expect(
ratio >= 0.45 && ratio <= 1.08,
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [45%, 108%]`,
).toBe(true);
}
console.log(
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}% / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 45–108%)`,
);
});
});