mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
531 lines
15 KiB
JavaScript
531 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Build server/assets/atlas/{admin0,admin1}.geojson.gz from geoBoundaries (gbOpen).
|
|
//
|
|
// Why: Atlas previously fetched country + sub-national boundaries from Natural Earth's
|
|
// GitHub `master` at runtime. Natural Earth is stale (e.g. it still shows Norway's
|
|
// pre-2020 counties) and depicts some contested territory in ways the project does not
|
|
// want (see nvkelso/natural-earth-vector#391). geoBoundaries (CC BY 4.0) is current,
|
|
// redistributable, and carries ISO 3166-2 codes on its per-country ADM1 files.
|
|
//
|
|
// This downloads the *simplified* per-country gbOpen ADM0 (countries) and ADM1
|
|
// (regions) layers from a pinned geoBoundaries revision, normalizes each feature to
|
|
// the property names the Atlas client/server already read, and writes two gzipped
|
|
// FeatureCollections that the server serves at runtime (no network at boot).
|
|
//
|
|
// geoBoundaries: CC BY 4.0 — https://www.geoboundaries.org/ (attribution required).
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import zlib from 'node:zlib';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas');
|
|
|
|
// Pinned geoBoundaries revision (override with GB_REF=<sha|branch|tag>). The LFS media
|
|
// endpoint resolves a commit SHA, branch, or tag in the <ref> path segment.
|
|
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f';
|
|
const MEDIA = (a3, level) =>
|
|
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
|
|
`/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`;
|
|
|
|
// Country borders come from CGAZ (the Comprehensive Global Administrative Zones composite)
|
|
// rather than per-country gbOpen ADM0: CGAZ is gap-filled, so it includes territories
|
|
// that gbOpen omits or folds away — notably Svalbard (inside Norway's geometry) and
|
|
// Greenland. The country layer only needs A3/A2/name, so CGAZ's lack of `shapeISO` is
|
|
// irrelevant. (gbOpen ADM0 maxes Norway at 71°N and has no Svalbard at all.)
|
|
const CGAZ_ADM0 =
|
|
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
|
|
`/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`;
|
|
|
|
const CONCURRENCY = 8;
|
|
const RETRIES = 3;
|
|
|
|
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
|
|
// Regional-Codes, plus user-assigned XKX/XK for Kosovo, which geoBoundaries ships but
|
|
// the upstream ISO list omits — #1609). Drives ADM1 enumeration (one gbOpen request per
|
|
// code; missing ones 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries
|
|
// keys by alpha-3 `shapeGroup`). A complete map — not the client's curated ~180 — is
|
|
// what restores the dropped territories (Greenland, Falklands, French Guiana, …).
|
|
const A3_TO_A2 = {
|
|
ABW: 'AW',
|
|
AFG: 'AF',
|
|
AGO: 'AO',
|
|
AIA: 'AI',
|
|
ALA: 'AX',
|
|
ALB: 'AL',
|
|
AND: 'AD',
|
|
ARE: 'AE',
|
|
ARG: 'AR',
|
|
ARM: 'AM',
|
|
ASM: 'AS',
|
|
ATA: 'AQ',
|
|
ATF: 'TF',
|
|
ATG: 'AG',
|
|
AUS: 'AU',
|
|
AUT: 'AT',
|
|
AZE: 'AZ',
|
|
BDI: 'BI',
|
|
BEL: 'BE',
|
|
BEN: 'BJ',
|
|
BES: 'BQ',
|
|
BFA: 'BF',
|
|
BGD: 'BD',
|
|
BGR: 'BG',
|
|
BHR: 'BH',
|
|
BHS: 'BS',
|
|
BIH: 'BA',
|
|
BLM: 'BL',
|
|
BLR: 'BY',
|
|
BLZ: 'BZ',
|
|
BMU: 'BM',
|
|
BOL: 'BO',
|
|
BRA: 'BR',
|
|
BRB: 'BB',
|
|
BRN: 'BN',
|
|
BTN: 'BT',
|
|
BVT: 'BV',
|
|
BWA: 'BW',
|
|
CAF: 'CF',
|
|
CAN: 'CA',
|
|
CCK: 'CC',
|
|
CHE: 'CH',
|
|
CHL: 'CL',
|
|
CHN: 'CN',
|
|
CIV: 'CI',
|
|
CMR: 'CM',
|
|
COD: 'CD',
|
|
COG: 'CG',
|
|
COK: 'CK',
|
|
COL: 'CO',
|
|
COM: 'KM',
|
|
CPV: 'CV',
|
|
CRI: 'CR',
|
|
CUB: 'CU',
|
|
CUW: 'CW',
|
|
CXR: 'CX',
|
|
CYM: 'KY',
|
|
CYP: 'CY',
|
|
CZE: 'CZ',
|
|
DEU: 'DE',
|
|
DJI: 'DJ',
|
|
DMA: 'DM',
|
|
DNK: 'DK',
|
|
DOM: 'DO',
|
|
DZA: 'DZ',
|
|
ECU: 'EC',
|
|
EGY: 'EG',
|
|
ERI: 'ER',
|
|
ESH: 'EH',
|
|
ESP: 'ES',
|
|
EST: 'EE',
|
|
ETH: 'ET',
|
|
FIN: 'FI',
|
|
FJI: 'FJ',
|
|
FLK: 'FK',
|
|
FRA: 'FR',
|
|
FRO: 'FO',
|
|
FSM: 'FM',
|
|
GAB: 'GA',
|
|
GBR: 'GB',
|
|
GEO: 'GE',
|
|
GGY: 'GG',
|
|
GHA: 'GH',
|
|
GIB: 'GI',
|
|
GIN: 'GN',
|
|
GLP: 'GP',
|
|
GMB: 'GM',
|
|
GNB: 'GW',
|
|
GNQ: 'GQ',
|
|
GRC: 'GR',
|
|
GRD: 'GD',
|
|
GRL: 'GL',
|
|
GTM: 'GT',
|
|
GUF: 'GF',
|
|
GUM: 'GU',
|
|
GUY: 'GY',
|
|
HKG: 'HK',
|
|
HMD: 'HM',
|
|
HND: 'HN',
|
|
HRV: 'HR',
|
|
HTI: 'HT',
|
|
HUN: 'HU',
|
|
IDN: 'ID',
|
|
IMN: 'IM',
|
|
IND: 'IN',
|
|
IOT: 'IO',
|
|
IRL: 'IE',
|
|
IRN: 'IR',
|
|
IRQ: 'IQ',
|
|
ISL: 'IS',
|
|
ISR: 'IL',
|
|
ITA: 'IT',
|
|
JAM: 'JM',
|
|
JEY: 'JE',
|
|
JOR: 'JO',
|
|
JPN: 'JP',
|
|
KAZ: 'KZ',
|
|
KEN: 'KE',
|
|
KGZ: 'KG',
|
|
KHM: 'KH',
|
|
KIR: 'KI',
|
|
KNA: 'KN',
|
|
KOR: 'KR',
|
|
KWT: 'KW',
|
|
LAO: 'LA',
|
|
LBN: 'LB',
|
|
LBR: 'LR',
|
|
LBY: 'LY',
|
|
LCA: 'LC',
|
|
LIE: 'LI',
|
|
LKA: 'LK',
|
|
LSO: 'LS',
|
|
LTU: 'LT',
|
|
LUX: 'LU',
|
|
LVA: 'LV',
|
|
MAC: 'MO',
|
|
MAF: 'MF',
|
|
MAR: 'MA',
|
|
MCO: 'MC',
|
|
MDA: 'MD',
|
|
MDG: 'MG',
|
|
MDV: 'MV',
|
|
MEX: 'MX',
|
|
MHL: 'MH',
|
|
MKD: 'MK',
|
|
MLI: 'ML',
|
|
MLT: 'MT',
|
|
MMR: 'MM',
|
|
MNE: 'ME',
|
|
MNG: 'MN',
|
|
MNP: 'MP',
|
|
MOZ: 'MZ',
|
|
MRT: 'MR',
|
|
MSR: 'MS',
|
|
MTQ: 'MQ',
|
|
MUS: 'MU',
|
|
MWI: 'MW',
|
|
MYS: 'MY',
|
|
MYT: 'YT',
|
|
NAM: 'NA',
|
|
NCL: 'NC',
|
|
NER: 'NE',
|
|
NFK: 'NF',
|
|
NGA: 'NG',
|
|
NIC: 'NI',
|
|
NIU: 'NU',
|
|
NLD: 'NL',
|
|
NOR: 'NO',
|
|
NPL: 'NP',
|
|
NRU: 'NR',
|
|
NZL: 'NZ',
|
|
OMN: 'OM',
|
|
PAK: 'PK',
|
|
PAN: 'PA',
|
|
PCN: 'PN',
|
|
PER: 'PE',
|
|
PHL: 'PH',
|
|
PLW: 'PW',
|
|
PNG: 'PG',
|
|
POL: 'PL',
|
|
PRI: 'PR',
|
|
PRK: 'KP',
|
|
PRT: 'PT',
|
|
PRY: 'PY',
|
|
PSE: 'PS',
|
|
PYF: 'PF',
|
|
QAT: 'QA',
|
|
REU: 'RE',
|
|
ROU: 'RO',
|
|
RUS: 'RU',
|
|
RWA: 'RW',
|
|
SAU: 'SA',
|
|
SDN: 'SD',
|
|
SEN: 'SN',
|
|
SGP: 'SG',
|
|
SGS: 'GS',
|
|
SHN: 'SH',
|
|
SJM: 'SJ',
|
|
SLB: 'SB',
|
|
SLE: 'SL',
|
|
SLV: 'SV',
|
|
SMR: 'SM',
|
|
SOM: 'SO',
|
|
SPM: 'PM',
|
|
SRB: 'RS',
|
|
SSD: 'SS',
|
|
STP: 'ST',
|
|
SUR: 'SR',
|
|
SVK: 'SK',
|
|
SVN: 'SI',
|
|
SWE: 'SE',
|
|
SWZ: 'SZ',
|
|
SXM: 'SX',
|
|
SYC: 'SC',
|
|
SYR: 'SY',
|
|
TCA: 'TC',
|
|
TCD: 'TD',
|
|
TGO: 'TG',
|
|
THA: 'TH',
|
|
TJK: 'TJ',
|
|
TKL: 'TK',
|
|
TKM: 'TM',
|
|
TLS: 'TL',
|
|
TON: 'TO',
|
|
TTO: 'TT',
|
|
TUN: 'TN',
|
|
TUR: 'TR',
|
|
TUV: 'TV',
|
|
TWN: 'TW',
|
|
TZA: 'TZ',
|
|
UGA: 'UG',
|
|
UKR: 'UA',
|
|
UMI: 'UM',
|
|
URY: 'UY',
|
|
USA: 'US',
|
|
UZB: 'UZ',
|
|
VAT: 'VA',
|
|
VCT: 'VC',
|
|
VEN: 'VE',
|
|
VGB: 'VG',
|
|
VIR: 'VI',
|
|
VNM: 'VN',
|
|
VUT: 'VU',
|
|
WLF: 'WF',
|
|
WSM: 'WS',
|
|
XKX: 'XK',
|
|
YEM: 'YE',
|
|
ZAF: 'ZA',
|
|
ZMB: 'ZM',
|
|
ZWE: 'ZW',
|
|
};
|
|
|
|
/**
|
|
* Countries whose sub-national level is not the one everybody else's ADM1 is.
|
|
*
|
|
* geoBoundaries' GBR ADM1 is the four constituent countries — England, Scotland,
|
|
* Wales, Northern Ireland — so an atlas built from it has no polygon below
|
|
* "England", and a London borough marked as visited has nothing to light up
|
|
* (#1974). Its ADM2 is the 216 counties and unitary authorities, which is the
|
|
* granularity the rest of the world gets from ADM1 and the granularity the
|
|
* pre-3.1.0 Natural Earth layer had.
|
|
*
|
|
* Deliberately a short, hand-picked list. ADM2 everywhere would multiply the
|
|
* bundle several times over for no gain: for most countries ADM1 already is the
|
|
* level people think in.
|
|
*/
|
|
const ADM_LEVEL = { GBR: 'ADM2' };
|
|
|
|
const COUNTRIES = Object.keys(A3_TO_A2); // every ISO alpha-3 code (ADM1 fetch list)
|
|
|
|
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
|
|
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF);
|
|
|
|
async function fetchGeo(url) {
|
|
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0]);
|
|
if (fs.existsSync(cacheFile)) {
|
|
const cached = fs.readFileSync(cacheFile, 'utf8');
|
|
return cached === '' ? null : JSON.parse(cached);
|
|
}
|
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
|
try {
|
|
const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } });
|
|
if (res.status === 404) {
|
|
fs.writeFileSync(cacheFile, '');
|
|
return null;
|
|
} // no file — skip
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const text = await res.text();
|
|
if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content');
|
|
const parsed = JSON.parse(text);
|
|
fs.writeFileSync(cacheFile, text);
|
|
return parsed;
|
|
} catch (err) {
|
|
if (attempt === RETRIES) {
|
|
console.warn(` ! ${url.split('/').slice(-1)[0]}: ${err.message}`);
|
|
return null;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Run async tasks with a fixed concurrency cap.
|
|
async function pool(items, worker) {
|
|
const results = [];
|
|
let i = 0;
|
|
const runners = Array.from({ length: CONCURRENCY }, async () => {
|
|
while (i < items.length) {
|
|
const idx = i++;
|
|
results[idx] = await worker(items[idx], idx);
|
|
}
|
|
});
|
|
await Promise.all(runners);
|
|
return results;
|
|
}
|
|
|
|
// Geometry size control. geoBoundaries' "_simplified" files still carry ~12-decimal
|
|
// coordinates, which dominate the JSON size. Quantizing to a fixed grid (rounding
|
|
// preserves topology — identical input coords map to identical output) and dropping
|
|
// the now-redundant consecutive duplicate points shrinks the bundles ~5-8x with no
|
|
// visible effect at the atlas' zoom range (3-10). ADM0 fills are viewed zoomed out, so
|
|
// they tolerate a coarser grid than ADM1 region borders.
|
|
const ADM0_DECIMALS = 2; // ~1.1 km
|
|
const ADM1_DECIMALS = 3; // ~110 m
|
|
|
|
function quantizeRing(ring, decimals) {
|
|
const m = 10 ** decimals;
|
|
const out = [];
|
|
let prevX, prevY;
|
|
for (const pt of ring) {
|
|
const x = Math.round(pt[0] * m) / m;
|
|
const y = Math.round(pt[1] * m) / m;
|
|
if (x === prevX && y === prevY) continue;
|
|
out.push([x, y]);
|
|
prevX = x;
|
|
prevY = y;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Quantize a (Multi)Polygon, dropping rings that collapse below a valid ring (<4 pts).
|
|
function quantizeGeometry(geom, decimals) {
|
|
if (!geom) return null;
|
|
if (geom.type === 'Polygon') {
|
|
const rings = geom.coordinates.map((r) => quantizeRing(r, decimals)).filter((r) => r.length >= 4);
|
|
return rings.length ? { type: 'Polygon', coordinates: rings } : null;
|
|
}
|
|
if (geom.type === 'MultiPolygon') {
|
|
const polys = geom.coordinates
|
|
.map((poly) => poly.map((r) => quantizeRing(r, decimals)).filter((r) => r.length >= 4))
|
|
.filter((poly) => poly.length);
|
|
return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null;
|
|
}
|
|
return geom;
|
|
}
|
|
|
|
// Normalize one CGAZ ADM0 feature (keyed by alpha-3 `shapeGroup`) to the property names
|
|
// the client country layer reads (ISO_A2/ADM0_A3/NAME/ADMIN). Returns null for the CRS
|
|
// pseudo-entry or anything without a group/geometry.
|
|
function normalizeAdm0Feature(f) {
|
|
const a3 = f.properties?.shapeGroup;
|
|
if (!a3) return null;
|
|
const name = f.properties?.shapeName || a3;
|
|
const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS);
|
|
if (!geometry) return null;
|
|
return {
|
|
type: 'Feature',
|
|
properties: { ISO_A2: A3_TO_A2[a3] || null, ADM0_A3: a3, NAME: name, ADMIN: name },
|
|
geometry,
|
|
};
|
|
}
|
|
|
|
function normalizeAdm1(geo, a3, countryName) {
|
|
if (!geo?.features) return [];
|
|
const a2 = A3_TO_A2[a3] || null;
|
|
// Ensure every region in a country ends up with a distinct iso_3166_2 — the Atlas
|
|
// marks/unmarks regions by this code, so duplicates make one mark light up the whole
|
|
// country.
|
|
const used = new Set();
|
|
const uniq = (base) => {
|
|
let code = base,
|
|
n = 2;
|
|
while (used.has(code)) code = `${base}-${n++}`;
|
|
used.add(code);
|
|
return code;
|
|
};
|
|
return geo.features
|
|
.map((f) => {
|
|
let name = f.properties?.shapeName || '';
|
|
// geoBoundaries erroneously names Guangdong Province as "Guangzhou Province" (Guangzhou is the capital city).
|
|
if (a3 === 'CHN' && name === 'Guangzhou Province') {
|
|
name = 'Guangdong Province';
|
|
}
|
|
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS);
|
|
if (!geometry) return null;
|
|
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
|
|
// fills it with the bare country code instead of a subdivision code — e.g. every
|
|
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
|
|
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
|
|
// stable, unique-per-country id from the region name so each region is independently
|
|
// markable.
|
|
const raw = f.properties?.shapeISO || '';
|
|
let code;
|
|
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
|
|
code = raw;
|
|
used.add(code);
|
|
} else if (a2) {
|
|
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`);
|
|
} else {
|
|
code = raw;
|
|
}
|
|
return {
|
|
type: 'Feature',
|
|
// Property names the Atlas region layer + server getRegionGeo already read.
|
|
properties: {
|
|
iso_a2: a2,
|
|
iso_3166_2: code,
|
|
name,
|
|
name_en: name,
|
|
admin: countryName,
|
|
},
|
|
geometry,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`);
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
|
|
// ADM0 (countries) — one comprehensive CGAZ file (large; cached). Also yields the
|
|
// English country name (shapeGroup → shapeName) used for the ADM1 `admin` field.
|
|
console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…');
|
|
const cgaz = await fetchGeo(CGAZ_ADM0);
|
|
const adm0Features = [];
|
|
const a3ToName = {};
|
|
for (const f of cgaz?.features || []) {
|
|
const nf = normalizeAdm0Feature(f);
|
|
if (nf) {
|
|
a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME;
|
|
adm0Features.push(nf);
|
|
}
|
|
}
|
|
|
|
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
|
|
console.log('[atlas-geo] downloading ADM1 (regions)…');
|
|
const adm1Raw = await pool(COUNTRIES, (a3) => fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1')));
|
|
const adm1Features = [];
|
|
let withCodes = 0;
|
|
COUNTRIES.forEach((a3, idx) => {
|
|
const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3);
|
|
for (const f of feats) if (f.properties.iso_3166_2) withCodes++;
|
|
adm1Features.push(...feats);
|
|
});
|
|
|
|
const write = (name, features) => {
|
|
const fc = { type: 'FeatureCollection', features };
|
|
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 });
|
|
const file = path.join(OUT_DIR, `${name}.geojson.gz`);
|
|
fs.writeFileSync(file, gz);
|
|
console.log(
|
|
`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`,
|
|
);
|
|
};
|
|
|
|
write('admin0', adm0Features);
|
|
write('admin1', adm1Features);
|
|
|
|
const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length);
|
|
console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`);
|
|
console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`);
|
|
console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|