source.fact.ngo a coherence.ngo project

pipeline/gen-gazetteer.mjs

raw ↗ · AGPL-3.0

// 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)`);