repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.6 KB · 133 lines · JavaScript Raw History
  1// Builds the map data from Natural Earth, via martynafford/natural-earth-geojson.
  2// Writes build/static_maps/world.json keyed by ISO_A2, plus one
  3// build/static_maps/admin1/{ISO_A2}.json per country. `bun run build:maps`.
  4
  5import { mkdir, writeFile } from "node:fs/promises";
  6import { resolve, dirname } from "node:path";
  7import { fileURLToPath } from "node:url";
  8import { topology } from "topojson-server";
  9
 10const __dirname = dirname(fileURLToPath(import.meta.url));
 11const OUT_DIR = resolve(__dirname, "../../build/static_maps");
 12
 13// 110m is plenty for the world view. admin-1 has to be 10m, since it is the
 14// only Natural Earth tier with full per-country coverage.
 15const BASE = "https://raw.githubusercontent.com/martynafford/natural-earth-geojson/master";
 16const ADMIN0_URL = `${BASE}/110m/cultural/ne_110m_admin_0_countries.json`;
 17const ADMIN1_URL = `${BASE}/10m/cultural/ne_10m_admin_1_states_provinces.json`;
 18
 19// 1e5 keeps coastlines smooth at screen sizes and still cuts the file size a
 20// long way against raw GeoJSON. d3-geo handles the dequantization.
 21const QUANTIZATION = 1e5;
 22
 23async function fetchJson(url) {
 24  process.stdout.write(`  GET ${url}\n`);
 25  const res = await fetch(url);
 26  if (!res.ok) throw new Error(`${res.status} ${res.statusText} - ${url}`);
 27  return res.json();
 28}
 29
 30// Natural Earth has a long-running bug where France and Norway get
 31// ISO_A2 = "-99", from an EU/Schengen dispute baked into the source. They are
 32// the only two, so map them back from ISO_A3.
 33const A3_TO_A2_OVERRIDES = {
 34  FRA: "FR",
 35  NOR: "NO",
 36};
 37
 38function normalizeCountryCode(props) {
 39  const code = props.ISO_A2 ?? props.iso_a2;
 40  if (code && code !== "-99" && code !== -99) return code;
 41  const eh = props.ISO_A2_EH ?? props.iso_a2_eh;
 42  if (eh && eh !== "-99" && eh !== -99) return eh;
 43  // ISO_A3 is also "-99" for the same disputed records, so fall back to
 44  // ADM0_A3 which Natural Earth always populates.
 45  const a3 = props.ADM0_A3 ?? props.adm0_a3 ?? props.ISO_A3 ?? props.iso_a3;
 46  if (a3 && a3 !== "-99" && A3_TO_A2_OVERRIDES[a3]) return A3_TO_A2_OVERRIDES[a3];
 47  return null;
 48}
 49
 50function trimCountryProps(feature) {
 51  // The client only needs a name and an ISO code, so the other Natural Earth
 52  // fields are dropped to keep the payload down.
 53  const p = feature.properties || {};
 54  const iso = normalizeCountryCode(p);
 55  return {
 56    ...feature,
 57    id: iso,
 58    properties: {
 59      iso: iso,
 60      name: p.NAME ?? p.ADMIN ?? p.name ?? "",
 61    },
 62  };
 63}
 64
 65function trimAdmin1Props(feature) {
 66  const p = feature.properties || {};
 67  // Natural Earth's `name` is the local form ("Bayern") and DB-IP returns the
 68  // English one ("Bavaria"), so both are kept and the lookup can match either.
 69  return {
 70    ...feature,
 71    properties: {
 72      iso_3166_2: p.iso_3166_2 ?? "",
 73      postal: p.postal ?? "",
 74      name: p.name ?? "",
 75      name_alt: p.name_alt ?? "",
 76    },
 77  };
 78}
 79
 80async function buildWorld(admin0) {
 81  const features = admin0.features
 82    .map(trimCountryProps)
 83    .filter((f) => f.id);
 84  const topo = topology({ countries: { type: "FeatureCollection", features } }, QUANTIZATION);
 85  const path = resolve(OUT_DIR, "world.json");
 86  await writeFile(path, JSON.stringify(topo));
 87  return { path, count: features.length, bytes: JSON.stringify(topo).length };
 88}
 89
 90async function buildAdmin1(admin1) {
 91  const byCountry = new Map();
 92  for (const f of admin1.features) {
 93    const iso = normalizeCountryCode(f.properties || {});
 94    if (!iso) continue;
 95    if (!byCountry.has(iso)) byCountry.set(iso, []);
 96    byCountry.get(iso).push(trimAdmin1Props(f));
 97  }
 98
 99  await mkdir(resolve(OUT_DIR, "admin1"), { recursive: true });
100
101  let totalBytes = 0;
102  for (const [iso, features] of byCountry) {
103    const topo = topology({ regions: { type: "FeatureCollection", features } }, QUANTIZATION);
104    const json = JSON.stringify(topo);
105    await writeFile(resolve(OUT_DIR, "admin1", `${iso}.json`), json);
106    totalBytes += json.length;
107  }
108  return { count: byCountry.size, bytes: totalBytes };
109}
110
111async function main() {
112  await mkdir(OUT_DIR, { recursive: true });
113
114  console.log("Downloading Natural Earth source data...");
115  const [admin0, admin1] = await Promise.all([
116    fetchJson(ADMIN0_URL),
117    fetchJson(ADMIN1_URL),
118  ]);
119
120  console.log("Building world topology...");
121  const world = await buildWorld(admin0);
122  console.log(`  ${world.count} countries -> ${world.path} (${(world.bytes / 1024).toFixed(1)} KB)`);
123
124  console.log("Building per-country admin-1 topologies...");
125  const a1 = await buildAdmin1(admin1);
126  console.log(`  ${a1.count} country files (${(a1.bytes / 1024).toFixed(1)} KB total)`);
127}
128
129main().catch((err) => {
130  console.error(err);
131  process.exit(1);
132});