pipeline/gen-gazetteer.mjs
// gen-gazetteer.mjs — regenerates pipeline/gazetteer.js, the vendored
// place-name lookup for city-level GIS placement. Source: Natural Earth 10m
// populated places (public domain), matching the site's NE v5.1.2 basemap
// lineage. The generated module is the committed artifact; the generator
// documents provenance and can be re-run to refresh.
//
// node pipeline/gen-gazetteer.mjs [path-to-ne_10m_populated_places_simple.geojson]
// (fetches the pinned source when the path is absent)
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import url from "node:url";
const SRC_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson";
const root = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
const outPath = path.join(root, "pipeline/gazetteer.js");
let geojsonPath = process.argv[2];
if (!geojsonPath || !fs.existsSync(geojsonPath)) {
geojsonPath = "/tmp/ne-pp.geojson";
if (!fs.existsSync(geojsonPath)) {
console.log("fetching Natural Earth populated places...");
const res = await fetch(SRC_URL);
if (!res.ok) { console.error(`fetch failed: HTTP ${res.status}`); process.exit(1); }
fs.writeFileSync(geojsonPath, await res.text());
}
}
const raw = fs.readFileSync(geojsonPath, "utf8");
const srcHash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 12);
const gj = JSON.parse(raw);
const norm = (s) => String(s || "")
.toLowerCase()
.replace(/[‘’]/g, "'")
.replace(/[^a-z0-9'\s-]/g, " ")
.replace(/-/g, " ")
.replace(/\s+/g, " ")
.trim();
const entries = new Map(); // "cc|name" -> [lat, lon, pop, display]
let skipped = 0;
for (const f of gj.features) {
const p = f.properties || {};
const cc = p.iso_a2 || p.adm0_a2 || "";
if (!/^[A-Z]{2}$/.test(cc)) { skipped++; continue; }
const name = p.nameascii || p.name;
if (!name) { skipped++; continue; }
const key = `${cc.toLowerCase()}|${norm(name)}`;
const value = [+p.latitude, +p.longitude, p.pop_max || 0, p.nameascii || p.name];
const prev = entries.get(key);
if (!prev || value[2] > prev[2]) entries.set(key, value); // keep the larger place on name collisions
const alt = p.namealt && norm(p.namealt) !== norm(name) ? `${cc.toLowerCase()}|${norm(p.namealt)}` : null;
if (alt && !entries.has(alt)) entries.set(alt, value);
}
const body = `// GENERATED by pipeline/gen-gazetteer.mjs — do not edit; regenerate instead.
// Source: Natural Earth 10m populated places, public domain.
// Pinned source sha256[:12] = ${srcHash}; keys are "cc|normalized-name".
export const GAZETTEER = ${JSON.stringify(Object.fromEntries(entries))};
export const GAZETTEER_SIZE = ${entries.size};
export function normalizePlaceName(s) {
return String(s || "").toLowerCase().replace(/[‘’]/g, "'").replace(/[^a-z0-9'\\s-]/g, " ").replace(/-/g, " ").replace(/\\s+/g, " ").trim();
}
export function lookupPlace(name, countryCode) {
const cc = String(countryCode || "").toLowerCase();
const n = normalizePlaceName(name);
if (!cc || !n) return null;
const direct = GAZETTEER[cc + "|" + n];
if (direct) return direct;
// Descriptive suffixes the distiller may add: "Jalud village", "Rafah city".
const words = n.split(" ");
while (words.length > 1) {
const last = words[words.length - 1];
if (!["village", "city", "town", "district", "province", "region", "governorate", "county", "state", "area", "suburb"].includes(last)) break;
words.pop();
const hit = GAZETTEER[cc + "|" + words.join(" ")];
if (hit) return hit;
}
return null;
}
`;
fs.writeFileSync(outPath, body);
console.log(`gazetteer: ${entries.size} place keys, ${skipped} skipped, sha ${srcHash} -> pipeline/gazetteer.js (${(fs.statSync(outPath).size / 1024).toFixed(0)}KB)`);