orchard
mirrorEvery 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
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});