doxa-maps/plugin-designs/my-upg-100-list/data/adjacency.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

278 lines
9.2 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// adjacency.mjs — slice 09b-2 §1(a–c)
//
// Queen-contiguity adjacency graph over the combined IND + CHN ADM1 polygons,
// plus a reusable contiguity test for the ZLL ("is this set of admin-1s one
// connected component?").
//
// No @turf in node_modules (verified: `ls node_modules/@turf` -> none), so this
// is a committed, dependency-free geometry test:
// - bbox prefilter (cheap reject)
// - queen contiguity = boundaries cross/touch OR a vertex of one ring lies
// within EPS of a vertex/edge of the other (tolerates gbOpen slivers and
// point-touching corners).
// Handles Polygon + MultiPolygon. Coords are [lng, lat] (gbOpen).
//
// Cache: data/adjacency.json (sorted keys + sorted neighbour arrays — no
// timestamps, byte-reproducible).
//
// Exports: buildAdjacency(), getAdjacency(), neighborsOf(), isContiguous(),
// ISLAND_ORPHANS.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = [
join(__dirname, 'gb-IND-ADM1.geojson'),
join(__dirname, 'gb-CHN-ADM1.geojson'),
];
const CACHE = join(__dirname, 'adjacency.json');
// Declared island-orphans — no land border, exempt from the connected-component
// assertion. EXACT shapeName strings as they appear in the geojson (verified by
// reading both files). "Japan" is not present in these two datasets.
export const ISLAND_ORPHANS = [
'Hainan Province', // CHN — bare "Hainan" is not the shapeName
'Lakshadweep', // IND
'Andaman and Nicobar Islands',// IND
// 'Japan' — not in IND/CHN ADM1 files; kept here for forward-compat callers.
'Japan',
];
// Queen tolerance in degrees. gbOpen ADM1 vertices on a shared border rarely
// coincide bit-for-bit; ~1e-4 deg ≈ 11 m at the equator catches sliver gaps
// without bridging genuinely separate admins (min real gap here is >> this).
const EPS = 1e-4;
const EPS2 = EPS * EPS;
// ---------------------------------------------------------------------------
// geometry helpers — all on [lng,lat] pairs
// ---------------------------------------------------------------------------
// every linear ring (outer + holes) of a Polygon/MultiPolygon -> array of rings
function ringsOf(geom) {
if (geom.type === 'Polygon') return geom.coordinates;
if (geom.type === 'MultiPolygon') return geom.coordinates.flat();
throw new Error('unsupported geometry: ' + geom.type);
}
function bboxOfRings(rings) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const ring of rings) {
for (const [x, y] of ring) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
return [minX, minY, maxX, maxY];
}
// bboxes overlap (inflated by EPS so point-touching corners survive the filter)
function bboxOverlap(a, b) {
return !(a[2] + EPS < b[0] || b[2] + EPS < a[0] ||
a[3] + EPS < b[1] || b[3] + EPS < a[1]);
}
// squared distance from point p to segment ab (planar; fine at admin scale)
function distSqPointSeg(p, a, b) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 === 0) {
const ex = p[0] - a[0], ey = p[1] - a[1];
return ex * ex + ey * ey;
}
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
if (t < 0) t = 0; else if (t > 1) t = 1;
const cx = a[0] + t * dx, cy = a[1] + t * dy;
const ex = p[0] - cx, ey = p[1] - cy;
return ex * ex + ey * ey;
}
// proper segment-intersection test (boundaries crossing each other)
function segDir(o, a, b) {
const v = (b[1] - o[1]) * (a[0] - o[0]) - (b[0] - o[0]) * (a[1] - o[1]);
if (v > 1e-14) return 1;
if (v < -1e-14) return -1;
return 0;
}
function onSeg(o, a, p) {
return Math.min(o[0], a[0]) - EPS <= p[0] && p[0] <= Math.max(o[0], a[0]) + EPS &&
Math.min(o[1], a[1]) - EPS <= p[1] && p[1] <= Math.max(o[1], a[1]) + EPS;
}
function segIntersect(p1, p2, p3, p4) {
const d1 = segDir(p3, p4, p1);
const d2 = segDir(p3, p4, p2);
const d3 = segDir(p1, p2, p3);
const d4 = segDir(p1, p2, p4);
if (d1 !== d2 && d3 !== d4) return true;
if (d1 === 0 && onSeg(p3, p4, p1)) return true;
if (d2 === 0 && onSeg(p3, p4, p2)) return true;
if (d3 === 0 && onSeg(p1, p2, p3)) return true;
if (d4 === 0 && onSeg(p1, p2, p4)) return true;
return false;
}
// Queen-adjacency between two admin geometries.
// Adjacent iff any boundary segments cross/touch OR any vertex of one ring is
// within EPS of an edge (or vertex) of the other ring. Either direction.
function adjacent(ringsA, bbA, ringsB, bbB) {
if (!bboxOverlap(bbA, bbB)) return false;
// 1) vertex-near-edge (both directions) — catches shared borders + corners
if (vertexNearEdge(ringsA, ringsB)) return true;
if (vertexNearEdge(ringsB, ringsA)) return true;
// 2) proper segment intersection — catches crossing boundaries w/o coincident
// vertices (rare here, but correct).
for (const ra of ringsA) {
for (let i = 0; i + 1 < ra.length; i++) {
const a1 = ra[i], a2 = ra[i + 1];
for (const rb of ringsB) {
for (let j = 0; j + 1 < rb.length; j++) {
if (segIntersect(a1, a2, rb[j], rb[j + 1])) return true;
}
}
}
}
return false;
}
function vertexNearEdge(ringsP, ringsQ) {
for (const rp of ringsP) {
for (const v of rp) {
for (const rq of ringsQ) {
for (let j = 0; j + 1 < rq.length; j++) {
if (distSqPointSeg(v, rq[j], rq[j + 1]) <= EPS2) return true;
}
}
}
}
return false;
}
// ---------------------------------------------------------------------------
// build
// ---------------------------------------------------------------------------
function loadUnits() {
const units = [];
for (const path of SRC) {
const fc = JSON.parse(readFileSync(path, 'utf8'));
for (const f of fc.features) {
const name = f.properties.shapeName;
const rings = ringsOf(f.geometry);
units.push({ name, rings, bbox: bboxOfRings(rings) });
}
}
return units;
}
// Build the adjacency map fresh from geojson. Returns { "<name>": Set<name> }.
export function buildAdjacency() {
const units = loadUnits();
const adj = {};
for (const u of units) adj[u.name] = new Set();
for (let i = 0; i < units.length; i++) {
for (let j = i + 1; j < units.length; j++) {
if (adjacent(units[i].rings, units[i].bbox, units[j].rings, units[j].bbox)) {
adj[units[i].name].add(units[j].name);
adj[units[j].name].add(units[i].name);
}
}
}
return adj;
}
// Serialise to a reproducible JSON shape: sorted keys, sorted neighbour arrays.
function toCacheObject(adj) {
const out = {};
for (const name of Object.keys(adj).sort()) {
out[name] = [...adj[name]].sort();
}
return out;
}
// Write data/adjacency.json (sorted, no timestamps).
export function writeCache(adj = buildAdjacency()) {
writeFileSync(CACHE, JSON.stringify(toCacheObject(adj), null, 2) + '\n');
return adj;
}
// Lazy singleton: load cache if present, else build + write.
let _adj = null;
export function getAdjacency() {
if (_adj) return _adj;
if (existsSync(CACHE)) {
const obj = JSON.parse(readFileSync(CACHE, 'utf8'));
_adj = {};
for (const [k, v] of Object.entries(obj)) _adj[k] = new Set(v);
} else {
_adj = writeCache();
}
return _adj;
}
// (c) convenience accessor for the partitioner
export function neighborsOf(name) {
const adj = getAdjacency();
if (!adj[name]) return [];
return [...adj[name]].sort();
}
// ---------------------------------------------------------------------------
// (b) contiguity test
// ---------------------------------------------------------------------------
// isContiguous(adminNames, { islandOrphans })
// -> { contiguous, components, orphansIgnored }
// Build the subgraph induced by adminNames, drop island-orphans from the
// connectivity requirement (but report them), BFS the rest. contiguous = the
// non-orphan members form exactly ONE connected component.
export function isContiguous(adminNames, { islandOrphans = ISLAND_ORPHANS } = {}) {
const adj = getAdjacency();
const orphanSet = new Set(islandOrphans);
const requested = new Set(adminNames);
// orphans that are actually in this request (reported, not required)
const orphansIgnored = [...requested].filter((n) => orphanSet.has(n)).sort();
// nodes that must form one component
const nodes = [...requested].filter((n) => !orphanSet.has(n));
if (nodes.length === 0) {
return { contiguous: true, components: [], orphansIgnored };
}
const inScope = new Set(nodes);
const seen = new Set();
const components = [];
for (const start of nodes) {
if (seen.has(start)) continue;
const comp = [];
const queue = [start];
seen.add(start);
while (queue.length) {
const cur = queue.shift();
comp.push(cur);
const nbrs = adj[cur] || new Set();
for (const nb of nbrs) {
if (inScope.has(nb) && !seen.has(nb)) {
seen.add(nb);
queue.push(nb);
}
}
}
components.push(comp.sort());
}
components.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
return {
contiguous: components.length === 1,
components,
orphansIgnored,
};
}