Generate public/region-adjacency.json: regionKey -> neighbourKeys, built from regions.geo.json (bbox proximity reject + @turf/boolean-intersects arbitration; symmetric, deduped, no self-refs). Deterministic + idempotent. This is the data backing the LLM's getNeighbours tool (Stage 2) which lets it move contiguous frontier regions instead of whole countries.
134 lines
6.3 KiB
JavaScript
134 lines
6.3 KiB
JavaScript
/**
|
|
* scripts/build-region-adjacency.mjs — build the precomputed region-adjacency
|
|
* manifest: `public/region-adjacency.json` = `{ "<regionKey>": ["<neighbourKey>", ...], ... }`.
|
|
*
|
|
* For every sub-region (region key, see `public/region-keys.json`) this records
|
|
* which *other* sub-regions it **borders** — i.e. shares an edge or a point with.
|
|
* The result is the data behind the model's `getNeighbours` tool (P2 of #16):
|
|
* given a set of region keys, the engine returns the union of their adjacency
|
|
* entries (excluding the queried keys), so the LLM can plan *frontier-biased,
|
|
* contiguous* territory transfers (a nation realistically gains/loses a region
|
|
* it already abuts) instead of whole countries.
|
|
*
|
|
* Method — bounding-box-pruned polygon intersection (turf is allowed in .mjs
|
|
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
|
* v0.17 note). For each unordered pair of regions (i < j) we first do a cheap
|
|
* bbox-proximity reject with a small gap tolerance (adjacent regions' bboxes
|
|
* touch, so their gap is ≈0), and only for the survivors do we run
|
|
* `@turf/boolean-intersects`, which returns true exactly when the two
|
|
* (Multi)Polygons share at least one point (edge or corner touch, or overlap).
|
|
* A hit adds each key to the other's set — adjacency is recorded **symmetrically**
|
|
* and **deduplicated** (a neighbour appears once even if the pair shares several
|
|
* disjoint border segments).
|
|
*
|
|
* Output contract (asserted by scripts/region-adjacency.test.ts):
|
|
* - one entry for **every** region key (including regions with no bordering
|
|
* neighbours, e.g. isolated islands — their list is simply empty);
|
|
* - adjacency is symmetric (A ∈ B's list ⟺ B ∈ A's list);
|
|
* - no key lists itself.
|
|
*
|
|
* Deterministic + idempotent: the input files are fixed, the intersection test
|
|
* is a pure geometric computation, and the output is written with keys and
|
|
* neighbour lists both sorted — so a re-run rewrites a byte-identical file.
|
|
*
|
|
* Usage: `node scripts/build-region-adjacency.mjs` (or `npm run build:region-adjacency`).
|
|
*/
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import bbox from '@turf/bbox';
|
|
import booleanIntersects from '@turf/boolean-intersects';
|
|
import { feature } from '@turf/helpers';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(here, '..');
|
|
|
|
// "Close enough" bbox gap (degrees) for the cheap reject. Adjacent regions'
|
|
// bboxes touch (gap ≈ 0); a small tolerance absorbs 4-dp coordinate rounding
|
|
// and digitization fuzz. It only prunes work — it never affects correctness,
|
|
// because `booleanIntersects` is the final arbiter (a pair is a neighbour only
|
|
// if the polygons actually share a point).
|
|
const GAP_DEG = 0.05; // ≈ 5.5 km
|
|
|
|
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
|
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
|
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
|
|
|
// Region records in stable file order, keyed by their region key.
|
|
const regions = [];
|
|
for (const f of geo.features ?? []) {
|
|
const id = f.id == null ? '' : String(f.id);
|
|
const key = idToKey[id];
|
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
|
regions.push({ key, feature: feature(f.geometry), bbox: bbox(f.geometry) });
|
|
}
|
|
|
|
if (regions.length === 0) {
|
|
throw new Error('no regions with valid region keys — check regions.geo.json / region-keys.json');
|
|
}
|
|
|
|
// bboxes are flat [minX, minY, maxX, maxY] (turf's BBox type).
|
|
function bboxesClose(a, b) {
|
|
return (
|
|
a[0] <= b[2] + GAP_DEG && // a.w <= b.e + gap
|
|
b[0] <= a[2] + GAP_DEG && // b.w <= a.e + gap
|
|
a[1] <= b[3] + GAP_DEG && // a.s <= b.n + gap
|
|
b[1] <= a[3] + GAP_DEG // b.s <= a.n + gap
|
|
);
|
|
}
|
|
|
|
// key -> set of neighbouring keys (deduped; a pair sharing several segments is
|
|
// recorded once).
|
|
const adj = new Map();
|
|
for (const r of regions) adj.set(r.key, new Set());
|
|
|
|
let pairChecks = 0;
|
|
let intersections = 0;
|
|
for (let i = 0; i < regions.length; i++) {
|
|
for (let j = i + 1; j < regions.length; j++) {
|
|
if (!bboxesClose(regions[i].bbox, regions[j].bbox)) continue; // cheap reject
|
|
pairChecks++;
|
|
let touches = false;
|
|
try {
|
|
touches = booleanIntersects(regions[i].feature, regions[j].feature);
|
|
} catch {
|
|
touches = false; // a degenerate geometry pair is treated as non-adjacent
|
|
}
|
|
if (touches) {
|
|
intersections++;
|
|
adj.get(regions[i].key).add(regions[j].key);
|
|
adj.get(regions[j].key).add(regions[i].key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- write idempotently: keys sorted, neighbour lists sorted ------------------
|
|
// Every region key gets an entry (an isolated island's list is simply empty).
|
|
const manifest = {};
|
|
for (const key of [...adj.keys()].sort()) {
|
|
manifest[key] = [...adj.get(key)].sort();
|
|
}
|
|
|
|
// Self-reference guard (can't happen — we only ever add the *other* region — but
|
|
// assert it so a future regression is loud rather than silent).
|
|
for (const [k, list] of Object.entries(manifest)) {
|
|
if (list.includes(k)) throw new Error(`region ${k} lists itself as a neighbour`);
|
|
}
|
|
|
|
writeFileSync(path.join(root, 'public', 'region-adjacency.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
|
|
// --- summary ------------------------------------------------------------------
|
|
const totalEntries = Object.values(manifest).reduce((n, l) => n + l.length, 0);
|
|
const withNeighbours = Object.values(manifest).filter((l) => l.length > 0).length;
|
|
const maxDegree = Math.max(0, ...Object.values(manifest).map((l) => l.length));
|
|
console.log(
|
|
`[build-region-adjacency] wrote public/region-adjacency.json — ${Object.keys(manifest).length} region keys, ` +
|
|
`${totalEntries / 2} border pairs (${totalEntries} directed entries), max degree ${maxDegree}, ` +
|
|
`${withNeighbours} with ≥1 neighbour (${pairChecks} bbox-close pairs tested, ${intersections} intersect)`,
|
|
);
|
|
// A couple of human-checkable examples (highest-degree regions, stable order).
|
|
const byDegree = Object.entries(manifest).sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
|
|
for (const [k, list] of byDegree.slice(0, 3)) {
|
|
console.log(` e.g. ${k} (deg ${list.length}) → ${list.slice(0, 4).join(', ')}${list.length > 4 ? ', …' : ''}`);
|
|
}
|