mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
772 lines
26 KiB
JavaScript
772 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Build the bundled administrative-boundary packs read by
|
||
* `src/data/adminBoundaries.js`:
|
||
*
|
||
* src/data/local_data/natural_earth/states_provinces.json
|
||
* Natural Earth 10m admin-1 states and provinces (public domain).
|
||
* src/data/local_data/us_census_counties/counties.json
|
||
* US Census Bureau cartographic boundary counties (public domain).
|
||
*
|
||
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
|
||
* byte for byte. Downloads are cached outside the repository (default: the
|
||
* system temp directory) and are never committed.
|
||
*
|
||
* Usage:
|
||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
|
||
*
|
||
* The provenance READMEs beside each pack record these parameters.
|
||
*/
|
||
|
||
import { createHash } from 'node:crypto';
|
||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { inflateRawSync } from 'node:zlib';
|
||
import { normalizeAdminName } from '../src/data/adminBoundaries.js';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const NE_COMMIT = 'ca96624a56bd078437bca8184e78163e5039ad19';
|
||
const NE_BASE = `https://raw.githubusercontent.com/nvkelso/natural-earth-vector/${NE_COMMIT}/geojson`;
|
||
|
||
export const SOURCES = Object.freeze({
|
||
countries: {
|
||
url: `${NE_BASE}/ne_10m_admin_0_countries.geojson`,
|
||
sha256: '239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255',
|
||
},
|
||
mapUnits: {
|
||
url: `${NE_BASE}/ne_10m_admin_0_map_units.geojson`,
|
||
sha256: '57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862',
|
||
},
|
||
admin1: {
|
||
url: `${NE_BASE}/ne_10m_admin_1_states_provinces.geojson`,
|
||
sha256: '22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5',
|
||
},
|
||
places: {
|
||
url: `${NE_BASE}/ne_10m_populated_places_simple.geojson`,
|
||
sha256: 'fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f',
|
||
},
|
||
counties: {
|
||
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip',
|
||
sha256: 'faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74',
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Pack parameters. The Douglas-Peucker tolerance scales with the unit's size
|
||
* (`toleranceFactor` × √area, clamped to `[minToleranceDeg, maxToleranceDeg]`)
|
||
* because a unit is viewed at a zoom that fits it: Texas keeps ~2 km detail,
|
||
* Bavaria ~1 km, Travis County ~150 m. Parts smaller than `minPartKm2` or than
|
||
* `minPartShare` of the largest part are dropped (the largest part always
|
||
* stays); holes under `minHoleKm2` are dropped.
|
||
*/
|
||
export const PARAMS = Object.freeze({
|
||
countries: {
|
||
toleranceFactor: 0.004,
|
||
minToleranceDeg: 0.005,
|
||
maxToleranceDeg: 0.02,
|
||
decimals: 3,
|
||
minPartKm2: 20,
|
||
minPartShare: 0.00003,
|
||
minHoleKm2: 20,
|
||
},
|
||
admin1: {
|
||
toleranceFactor: 0.004,
|
||
minToleranceDeg: 0.005,
|
||
maxToleranceDeg: 0.02,
|
||
decimals: 3,
|
||
minPartKm2: 20,
|
||
minPartShare: 0.0003,
|
||
minHoleKm2: 20,
|
||
},
|
||
counties: {
|
||
toleranceFactor: 0.003,
|
||
minToleranceDeg: 0.0005,
|
||
maxToleranceDeg: 0.01,
|
||
decimals: 4,
|
||
minPartKm2: 1,
|
||
minPartShare: 0.001,
|
||
minHoleKm2: 1,
|
||
},
|
||
// A bare admin-1 name is marked ambiguous — the resolver then lets a
|
||
// geocoder decide — when it is also a country, the name of a city of at
|
||
// least `insideMinPopulation` inside the unit's box ("New York", "Madrid"),
|
||
// or the name of a city of at least `elsewhereMinPopulation` outside it
|
||
// ("Santa Fe", "Victoria"). A unit no larger than `cityStateMaxKm2` whose
|
||
// own city lies inside is the city itself (Berlin, Vienna, Paris): it is
|
||
// marked `cityState` and may be named alone however small.
|
||
ambiguity: {
|
||
insideMinPopulation: 200_000,
|
||
elsewhereMinPopulation: 50_000,
|
||
cityStateMaxKm2: 1_000,
|
||
},
|
||
});
|
||
|
||
const args = process.argv.slice(2);
|
||
const cacheDir = path.resolve(
|
||
args.includes('--cache')
|
||
? args[args.indexOf('--cache') + 1]
|
||
: path.join(os.tmpdir(), 'gev-admin-packs'),
|
||
);
|
||
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
|
||
|
||
export async function fetchPinned(name, source = SOURCES[name]) {
|
||
const { url, sha256 } = source;
|
||
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
|
||
let bytes = await readFile(file).catch(() => null);
|
||
if (!bytes) {
|
||
console.log(`Downloading ${url}`);
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
||
bytes = Buffer.from(await response.arrayBuffer());
|
||
await mkdir(cacheDir, { recursive: true });
|
||
await writeFile(file, bytes);
|
||
}
|
||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||
if (digest !== sha256)
|
||
throw new Error(
|
||
`${name}: SHA-256 ${digest} does not match the pinned ${sha256}`,
|
||
);
|
||
return { bytes, digest };
|
||
}
|
||
|
||
// ── geometry ────────────────────────────────────────────────────────────
|
||
|
||
const EARTH_RADIUS_KM = 6371;
|
||
const toRad = (d) => (d * Math.PI) / 180;
|
||
|
||
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
|
||
export function ringAreaKm2(ring) {
|
||
const n = ring.length;
|
||
if (n < 3) return 0;
|
||
let sum = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const [lon1, lat1] = ring[i];
|
||
const [lon2, lat2] = ring[(i + 1) % n];
|
||
sum +=
|
||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||
}
|
||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||
}
|
||
|
||
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
|
||
export function signedArea(ring) {
|
||
let sum = 0;
|
||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
|
||
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
|
||
return sum / 2;
|
||
}
|
||
|
||
function segDist(p, a, b) {
|
||
let [x, y] = p;
|
||
const [x1, y1] = a;
|
||
const [x2, y2] = b;
|
||
const dx = x2 - x1;
|
||
const dy = y2 - y1;
|
||
if (dx !== 0 || dy !== 0) {
|
||
const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
|
||
if (t > 1) {
|
||
x -= x2;
|
||
y -= y2;
|
||
return Math.hypot(x, y);
|
||
}
|
||
if (t > 0) {
|
||
x -= x1 + dx * t;
|
||
y -= y1 + dy * t;
|
||
return Math.hypot(x, y);
|
||
}
|
||
}
|
||
return Math.hypot(x - x1, y - y1);
|
||
}
|
||
|
||
/** Iterative Douglas-Peucker over an open point list. */
|
||
function douglasPeucker(points, tolerance) {
|
||
if (points.length <= 2) return points.slice();
|
||
const keep = new Uint8Array(points.length);
|
||
keep[0] = keep[points.length - 1] = 1;
|
||
const stack = [[0, points.length - 1]];
|
||
while (stack.length) {
|
||
const [first, last] = stack.pop();
|
||
let maxDist = 0;
|
||
let index = -1;
|
||
for (let i = first + 1; i < last; i++) {
|
||
const d = segDist(points[i], points[first], points[last]);
|
||
if (d > maxDist) {
|
||
maxDist = d;
|
||
index = i;
|
||
}
|
||
}
|
||
if (maxDist > tolerance && index !== -1) {
|
||
keep[index] = 1;
|
||
stack.push([first, index], [index, last]);
|
||
}
|
||
}
|
||
return points.filter((_, i) => keep[i]);
|
||
}
|
||
|
||
/**
|
||
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
|
||
* farthest vertex from the start so Douglas-Peucker has two anchored halves
|
||
* (a closed ring has no chord to measure against). Returns null when fewer
|
||
* than `minVertices` distinct vertices survive (four by default; a pack that
|
||
* keeps triangles passes three).
|
||
*/
|
||
function simplifyRing(ring, tolerance, decimals, minVertices = 4) {
|
||
let open = ring.slice();
|
||
const first = open[0];
|
||
const last = open[open.length - 1];
|
||
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
|
||
open = open.slice(0, -1);
|
||
if (open.length < minVertices) return null;
|
||
let far = 1;
|
||
let farDist = -1;
|
||
for (let i = 1; i < open.length; i++) {
|
||
const d = Math.hypot(open[i][0] - first[0], open[i][1] - first[1]);
|
||
if (d > farDist) {
|
||
farDist = d;
|
||
far = i;
|
||
}
|
||
}
|
||
const a = douglasPeucker(open.slice(0, far + 1), tolerance);
|
||
const b = douglasPeucker([...open.slice(far), first], tolerance);
|
||
const simplified = [...a, ...b.slice(1, -1)];
|
||
const factor = 10 ** decimals;
|
||
const out = [];
|
||
for (const [lon, lat] of simplified) {
|
||
const p = [
|
||
Math.round(lon * factor) / factor,
|
||
Math.round(lat * factor) / factor,
|
||
];
|
||
const prev = out[out.length - 1];
|
||
if (prev && prev[0] === p[0] && prev[1] === p[1]) continue;
|
||
out.push(p);
|
||
}
|
||
while (
|
||
out.length > 1 &&
|
||
out[0][0] === out[out.length - 1][0] &&
|
||
out[0][1] === out[out.length - 1][1]
|
||
)
|
||
out.pop();
|
||
return out.length >= minVertices ? out : null;
|
||
}
|
||
|
||
/**
|
||
* Simplify polygons ([outer, ...holes][]): drop parts and holes under the
|
||
* area floors (the largest part always stays), orient outers
|
||
* counter-clockwise and holes clockwise, sort parts by area descending.
|
||
*/
|
||
export function simplifyPolygons(polygons, params, tolerance, decimals) {
|
||
const minVertices = params.minRingVertices ?? 4;
|
||
const parts = [];
|
||
for (const poly of polygons) {
|
||
const outer = simplifyRing(poly[0], tolerance, decimals, minVertices);
|
||
if (!outer) continue;
|
||
const area = ringAreaKm2(outer);
|
||
if (signedArea(outer) < 0) outer.reverse();
|
||
const holes = [];
|
||
for (const hole of poly.slice(1)) {
|
||
const ring = simplifyRing(hole, tolerance, decimals, minVertices);
|
||
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
|
||
if (signedArea(ring) > 0) ring.reverse();
|
||
holes.push(ring);
|
||
}
|
||
parts.push({ rings: [outer, ...holes], area });
|
||
}
|
||
parts.sort((x, y) => y.area - x.area);
|
||
const floor = Math.max(
|
||
params.minPartKm2,
|
||
(parts[0]?.area || 0) * params.minPartShare,
|
||
);
|
||
return parts
|
||
.filter((part, index) => index === 0 || part.area >= floor)
|
||
.map((part) => part.rings);
|
||
}
|
||
|
||
/**
|
||
* Simplify one unit at its size-scaled tolerance. A unit too small to
|
||
* survive the pack precision (a Maldives atoll) is kept at one more decimal
|
||
* and a fifth of the minimum tolerance, or failing that unsimplified at two
|
||
* more decimals; the caller records `d`.
|
||
*/
|
||
export function unitTolerance(polygons, params) {
|
||
const sourceArea = polygons.reduce(
|
||
(sum, poly) => sum + ringAreaKm2(poly[0]),
|
||
0,
|
||
);
|
||
return Math.min(
|
||
params.maxToleranceDeg,
|
||
Math.max(
|
||
params.minToleranceDeg,
|
||
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
|
||
),
|
||
);
|
||
}
|
||
|
||
export function simplifyUnit(polygons, params) {
|
||
const tolerance = unitTolerance(polygons, params);
|
||
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
|
||
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
|
||
const fine = simplifyPolygons(
|
||
polygons,
|
||
params,
|
||
params.minToleranceDeg / 5,
|
||
params.decimals + 1,
|
||
);
|
||
if (fine.length) return { polygons: fine, decimals: params.decimals + 1 };
|
||
// Smaller still (Vatican City): the source ring as drawn.
|
||
const exact = simplifyPolygons(polygons, params, 0, params.decimals + 2);
|
||
return { polygons: exact, decimals: params.decimals + 2 };
|
||
}
|
||
|
||
/**
|
||
* Encode a rounded open ring as integers in units of 10^-decimals degrees:
|
||
* the first vertex absolute, every later vertex as a delta from the previous
|
||
* one — [lon0, lat0, dLon1, dLat1, …]. Decoded by `decodeRing` in
|
||
* src/data/adminBoundaries.js.
|
||
*/
|
||
function encodeRing(ring, decimals) {
|
||
const factor = 10 ** decimals;
|
||
const out = [];
|
||
let px = 0;
|
||
let py = 0;
|
||
for (const [lon, lat] of ring) {
|
||
const x = Math.round(lon * factor);
|
||
const y = Math.round(lat * factor);
|
||
out.push(x - px, y - py);
|
||
px = x;
|
||
py = y;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function encodeUnit({ polygons, decimals }) {
|
||
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
|
||
}
|
||
|
||
function geojsonPolygons(geometry) {
|
||
if (geometry?.type === 'Polygon') return [geometry.coordinates];
|
||
if (geometry?.type === 'MultiPolygon') return geometry.coordinates;
|
||
return [];
|
||
}
|
||
|
||
function bboxOf(polygons) {
|
||
let w = Infinity;
|
||
let s = Infinity;
|
||
let e = -Infinity;
|
||
let n = -Infinity;
|
||
for (const poly of polygons)
|
||
for (const [lon, lat] of poly[0]) {
|
||
if (lon < w) w = lon;
|
||
if (lon > e) e = lon;
|
||
if (lat < s) s = lat;
|
||
if (lat > n) n = lat;
|
||
}
|
||
return [w, s, e, n];
|
||
}
|
||
|
||
// ── zip / shapefile / dbf ───────────────────────────────────────────────
|
||
|
||
/** Read named entries from a zip archive (stored or deflated). */
|
||
export function unzip(buffer) {
|
||
let eocd = buffer.length - 22;
|
||
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
|
||
if (eocd < 0) throw new Error('zip: end of central directory not found');
|
||
const count = buffer.readUInt16LE(eocd + 10);
|
||
let offset = buffer.readUInt32LE(eocd + 16);
|
||
const files = new Map();
|
||
for (let i = 0; i < count; i++) {
|
||
if (buffer.readUInt32LE(offset) !== 0x02014b50)
|
||
throw new Error('zip: bad central directory entry');
|
||
const method = buffer.readUInt16LE(offset + 10);
|
||
const compressed = buffer.readUInt32LE(offset + 20);
|
||
const nameLength = buffer.readUInt16LE(offset + 28);
|
||
const extraLength = buffer.readUInt16LE(offset + 30);
|
||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||
const local = buffer.readUInt32LE(offset + 42);
|
||
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
|
||
const dataStart =
|
||
local +
|
||
30 +
|
||
buffer.readUInt16LE(local + 26) +
|
||
buffer.readUInt16LE(local + 28);
|
||
const data = buffer.subarray(dataStart, dataStart + compressed);
|
||
if (method === 0) files.set(name, data);
|
||
else if (method === 8) files.set(name, inflateRawSync(data));
|
||
else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||
offset += 46 + nameLength + extraLength + commentLength;
|
||
}
|
||
return files;
|
||
}
|
||
|
||
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
|
||
export function readShp(buffer) {
|
||
const shapes = [];
|
||
let offset = 100;
|
||
while (offset < buffer.length) {
|
||
const length = buffer.readInt32BE(offset + 4) * 2;
|
||
const content = offset + 8;
|
||
const type = buffer.readInt32LE(content);
|
||
const rings = [];
|
||
if (type === 5) {
|
||
const numParts = buffer.readInt32LE(content + 36);
|
||
const numPoints = buffer.readInt32LE(content + 40);
|
||
const partsAt = content + 44;
|
||
const pointsAt = partsAt + numParts * 4;
|
||
for (let p = 0; p < numParts; p++) {
|
||
const start = buffer.readInt32LE(partsAt + p * 4);
|
||
const end =
|
||
p + 1 < numParts
|
||
? buffer.readInt32LE(partsAt + (p + 1) * 4)
|
||
: numPoints;
|
||
const ring = [];
|
||
for (let i = start; i < end; i++)
|
||
ring.push([
|
||
buffer.readDoubleLE(pointsAt + i * 16),
|
||
buffer.readDoubleLE(pointsAt + i * 16 + 8),
|
||
]);
|
||
rings.push(ring);
|
||
}
|
||
} else if (type !== 0) {
|
||
throw new Error(`shp: unsupported shape type ${type}`);
|
||
}
|
||
shapes.push(rings);
|
||
offset = content + length;
|
||
}
|
||
return shapes;
|
||
}
|
||
|
||
/** dBASE records as plain objects (character and numeric fields). */
|
||
export function readDbf(buffer) {
|
||
const count = buffer.readUInt32LE(4);
|
||
const headerLength = buffer.readUInt16LE(8);
|
||
const recordLength = buffer.readUInt16LE(10);
|
||
const fields = [];
|
||
for (let at = 32; buffer[at] !== 0x0d; at += 32) {
|
||
fields.push({
|
||
name: buffer.toString('latin1', at, at + 11).replace(/\0.*$/, ''),
|
||
length: buffer[at + 16],
|
||
});
|
||
}
|
||
const records = [];
|
||
for (let r = 0; r < count; r++) {
|
||
let at = headerLength + r * recordLength + 1;
|
||
const record = {};
|
||
for (const field of fields) {
|
||
record[field.name] = buffer
|
||
.toString('utf8', at, at + field.length)
|
||
.trim();
|
||
at += field.length;
|
||
}
|
||
records.push(record);
|
||
}
|
||
return records;
|
||
}
|
||
|
||
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
|
||
export function shpPolygons(rings) {
|
||
const outers = [];
|
||
const holes = [];
|
||
for (const ring of rings) {
|
||
if (ring.length < 4) continue;
|
||
(signedArea(ring) < 0 ? outers : holes).push(ring);
|
||
}
|
||
const polygons = outers.map((outer) => [outer]);
|
||
for (const hole of holes) {
|
||
const [x, y] = hole[0];
|
||
const owner = polygons.find(([outer]) => pointInRing(outer, x, y));
|
||
if (owner) owner.push(hole);
|
||
else polygons.push([hole.slice().reverse()]);
|
||
}
|
||
return polygons;
|
||
}
|
||
|
||
export function pointInRing(ring, x, y) {
|
||
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;
|
||
}
|
||
|
||
// ── packs ────────────────────────────────────────────────────────────────
|
||
|
||
const LATIN = /^[\p{Script=Latin}\p{N}\s.,'’()\-/&]+$/u;
|
||
|
||
function uniqueNames(values, exclude) {
|
||
const seen = new Set(exclude.map(normalizeAdminName));
|
||
const out = [];
|
||
for (const value of values) {
|
||
const text = String(value || '').trim();
|
||
const key = normalizeAdminName(text);
|
||
if (!text || !key || seen.has(key) || !LATIN.test(text)) continue;
|
||
seen.add(key);
|
||
out.push(text);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function buildCountries() {
|
||
const source = await fetchPinned('countries');
|
||
const mapUnits = await fetchPinned('mapUnits');
|
||
const params = PARAMS.countries;
|
||
const features = [];
|
||
for (const { properties: p, geometry } of JSON.parse(source.bytes).features) {
|
||
const unit = simplifyUnit(geojsonPolygons(geometry), params);
|
||
if (!unit.polygons.length) continue;
|
||
const name = p.ADMIN || p.NAME_EN || p.NAME;
|
||
features.push({
|
||
name,
|
||
alt: uniqueNames(
|
||
[p.NAME, p.NAME_LONG, p.NAME_EN, p.FORMAL_EN, p.BRK_NAME].filter(
|
||
Boolean,
|
||
),
|
||
[name],
|
||
),
|
||
country: name,
|
||
iso: p.ADM0_A3,
|
||
iso2: p.ISO_A2_EH || p.ISO_A2,
|
||
type: 'Country',
|
||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||
polygons: encodeUnit(unit),
|
||
});
|
||
}
|
||
for (const { properties: p, geometry } of JSON.parse(mapUnits.bytes)
|
||
.features) {
|
||
if (p.ADM0_A3 !== 'GBR') continue;
|
||
const unit = simplifyUnit(geojsonPolygons(geometry), params);
|
||
features.push({
|
||
name: p.GEOUNIT,
|
||
country: p.ADMIN,
|
||
iso: p.GU_A3,
|
||
iso2: 'GB',
|
||
type: 'Constituent country',
|
||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||
polygons: encodeUnit(unit),
|
||
});
|
||
}
|
||
features.sort((a, b) => a.name.localeCompare(b.name));
|
||
return {
|
||
meta: {
|
||
title: 'Natural Earth admin-0 countries',
|
||
mapUnits: SOURCES.mapUnits,
|
||
source: 'ne_10m_admin_0_countries',
|
||
url: SOURCES.countries.url,
|
||
sha256: source.digest,
|
||
commit: NE_COMMIT,
|
||
license:
|
||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||
decimals: params.decimals,
|
||
curation: params,
|
||
script: 'scripts/build-admin-packs.mjs',
|
||
},
|
||
features,
|
||
};
|
||
}
|
||
|
||
async function buildAdmin1() {
|
||
const params = PARAMS.admin1;
|
||
const source = await fetchPinned('admin1');
|
||
const places = await fetchPinned('places');
|
||
const collection = JSON.parse(source.bytes.toString('utf8'));
|
||
const { ambiguity } = PARAMS;
|
||
const cities = JSON.parse(places.bytes.toString('utf8'))
|
||
.features.map((f) => f.properties)
|
||
.filter((p) => p.pop_max >= ambiguity.elsewhereMinPopulation)
|
||
.map((p) => ({
|
||
keys: new Set([p.name, p.nameascii].map(normalizeAdminName)),
|
||
lon: p.longitude,
|
||
lat: p.latitude,
|
||
population: p.pop_max,
|
||
country: p.adm0name,
|
||
}));
|
||
const countryNames = new Set();
|
||
for (const { properties: p } of collection.features)
|
||
for (const name of [p.admin, p.geonunit])
|
||
if (name) countryNames.add(normalizeAdminName(name));
|
||
|
||
const features = [];
|
||
for (const { properties: p, geometry } of collection.features) {
|
||
const name = String(p.name || '').trim();
|
||
if (!name) continue;
|
||
const unit = simplifyUnit(geojsonPolygons(geometry), params);
|
||
if (!unit.polygons.length) continue;
|
||
const nameEn =
|
||
p.name_en && normalizeAdminName(p.name_en) !== normalizeAdminName(name)
|
||
? String(p.name_en).trim()
|
||
: null;
|
||
const alt = uniqueNames(
|
||
String(p.name_alt || '').split('|'),
|
||
[name, nameEn].filter(Boolean),
|
||
);
|
||
const bbox = bboxOf(unit.polygons);
|
||
const areaKm2 = unit.polygons.reduce(
|
||
(sum, poly) => sum + ringAreaKm2(poly[0]),
|
||
0,
|
||
);
|
||
const keys = new Set(
|
||
[name, nameEn, ...alt].filter(Boolean).map(normalizeAdminName),
|
||
);
|
||
const amb = [];
|
||
const ambAbroad = [];
|
||
let cityState = false;
|
||
const inBox = (c) =>
|
||
c.lon >= bbox[0] &&
|
||
c.lon <= bbox[2] &&
|
||
c.lat >= bbox[1] &&
|
||
c.lat <= bbox[3];
|
||
for (const key of keys) {
|
||
const namesakes = cities.filter((c) => c.keys.has(key));
|
||
const ownCity = namesakes.some(
|
||
(c) => inBox(c) && c.population >= ambiguity.insideMinPopulation,
|
||
);
|
||
const cityInside = ownCity && areaKm2 > ambiguity.cityStateMaxKm2;
|
||
if (ownCity && !cityInside) cityState = true;
|
||
const elsewhere = namesakes.filter((c) => !inBox(c));
|
||
if (
|
||
countryNames.has(key) ||
|
||
cityInside ||
|
||
elsewhere.some((c) => c.country === p.admin)
|
||
)
|
||
amb.push(key);
|
||
// Only abroad: "Victoria, Australia" settles it; "Victoria" alone not.
|
||
else if (elsewhere.length) ambAbroad.push(key);
|
||
}
|
||
const postal = String(p.postal || '').trim();
|
||
features.push({
|
||
name,
|
||
...(nameEn ? { nameEn } : {}),
|
||
...(alt.length ? { alt } : {}),
|
||
...(/^[A-Z]{2}$/.test(postal) ? { postal } : {}),
|
||
type: p.type_en || p.type || null,
|
||
...(Number.isFinite(p.min_label) ? { rank: p.min_label } : {}),
|
||
country: p.admin,
|
||
iso2: p.iso_a2 && p.iso_a2 !== '-99' ? p.iso_a2 : null,
|
||
iso: p.iso_3166_2 && !p.iso_3166_2.includes('~') ? p.iso_3166_2 : null,
|
||
...(amb.length ? { amb: amb.sort() } : {}),
|
||
...(ambAbroad.length ? { ambAbroad: ambAbroad.sort() } : {}),
|
||
...(cityState ? { cityState } : {}),
|
||
...(Number.isFinite(p.longitude) && Number.isFinite(p.latitude)
|
||
? {
|
||
label: [
|
||
Math.round(p.longitude * 1000) / 1000,
|
||
Math.round(p.latitude * 1000) / 1000,
|
||
],
|
||
}
|
||
: {}),
|
||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||
polygons: encodeUnit(unit),
|
||
});
|
||
}
|
||
features.sort(
|
||
(a, b) =>
|
||
a.country.localeCompare(b.country) ||
|
||
a.name.localeCompare(b.name) ||
|
||
(a.iso || '').localeCompare(b.iso || ''),
|
||
);
|
||
return {
|
||
meta: {
|
||
title: 'Natural Earth admin-1 states and provinces',
|
||
source: 'ne_10m_admin_1_states_provinces',
|
||
url: SOURCES.admin1.url,
|
||
sha256: source.digest,
|
||
ambiguitySource: SOURCES.places.url,
|
||
ambiguitySha256: places.digest,
|
||
commit: NE_COMMIT,
|
||
license:
|
||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||
schema:
|
||
'features[]: name, nameEn?, alt?[], postal?, type, rank? (min_label: the zoom at which Natural Earth labels the unit; lower is more prominent), country, iso2, iso (ISO 3166-2), amb?[] (normalized names that are also a country or a city — alone or with the country named), ambAbroad?[] (names shared only with a city in another country — ambiguous alone, settled by naming the country), cityState? (the unit is its own city), label? [lon, lat] label point, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
|
||
decimals: params.decimals,
|
||
curation: { ...params, ambiguity },
|
||
script: 'scripts/build-admin-packs.mjs',
|
||
},
|
||
features,
|
||
};
|
||
}
|
||
|
||
async function buildCounties() {
|
||
const params = PARAMS.counties;
|
||
const source = await fetchPinned('counties');
|
||
const files = unzip(source.bytes);
|
||
const base = path.basename(new URL(SOURCES.counties.url).pathname, '.zip');
|
||
const shapes = readShp(files.get(`${base}.shp`));
|
||
const records = readDbf(files.get(`${base}.dbf`));
|
||
if (shapes.length !== records.length)
|
||
throw new Error('counties: shp/dbf record counts differ');
|
||
const features = [];
|
||
records.forEach((record, index) => {
|
||
const unit = simplifyUnit(shpPolygons(shapes[index]), params);
|
||
if (!unit.polygons.length) return;
|
||
const lsad = record.NAMELSAD.startsWith(record.NAME)
|
||
? record.NAMELSAD.slice(record.NAME.length).trim()
|
||
: '';
|
||
features.push({
|
||
name: record.NAME,
|
||
full: record.NAMELSAD,
|
||
...(lsad ? { lsad } : {}),
|
||
state: record.STATE_NAME,
|
||
st: record.STUSPS,
|
||
geoid: record.GEOID,
|
||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||
polygons: encodeUnit(unit),
|
||
});
|
||
});
|
||
features.sort((a, b) => a.geoid.localeCompare(b.geoid));
|
||
return {
|
||
meta: {
|
||
title: 'US Census Bureau cartographic boundary counties',
|
||
source: base,
|
||
url: SOURCES.counties.url,
|
||
sha256: source.digest,
|
||
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
|
||
schema:
|
||
'features[]: name, full (NAMELSAD), lsad? (County, Parish, Borough, …), state, st (USPS), geoid, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
|
||
decimals: params.decimals,
|
||
curation: params,
|
||
script: 'scripts/build-admin-packs.mjs',
|
||
},
|
||
features,
|
||
};
|
||
}
|
||
|
||
async function writePack(relative, pack) {
|
||
const file = path.join(ROOT, relative);
|
||
await mkdir(path.dirname(file), { recursive: true });
|
||
const text = JSON.stringify(pack) + '\n';
|
||
await writeFile(file, text);
|
||
const parts = pack.features.reduce((n, f) => n + f.polygons.length, 0);
|
||
const vertices = pack.features.reduce(
|
||
(n, f) => n + f.polygons.flat().reduce((m, ring) => m + ring.length / 2, 0),
|
||
0,
|
||
);
|
||
console.log(
|
||
`${relative}: ${pack.features.length} features, ${parts} parts, ${vertices} vertices, ${Buffer.byteLength(text)} bytes`,
|
||
);
|
||
}
|
||
|
||
const isMain =
|
||
process.argv[1] &&
|
||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
|
||
if (isMain) {
|
||
if (!only || only === 'countries')
|
||
await writePack(
|
||
'src/data/local_data/natural_earth/countries.json',
|
||
await buildCountries(),
|
||
);
|
||
if (!only || only === 'admin1')
|
||
await writePack(
|
||
'src/data/local_data/natural_earth/states_provinces.json',
|
||
await buildAdmin1(),
|
||
);
|
||
if (!only || only === 'counties')
|
||
await writePack(
|
||
'src/data/local_data/us_census_counties/counties.json',
|
||
await buildCounties(),
|
||
);
|
||
}
|