Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1// Build static map assets from Natural Earth.
2//
3// Downloads admin-0 (countries) and admin-1 (states / provinces) GeoJSON,
4// converts to TopoJSON with quantization, and writes:
5//
6// analytics/static_maps/world.json - all countries, keyed by ISO_A2
7// analytics/static_maps/admin1/{ISO_A2}.json - one file per country
8//
9// Source: martynafford/natural-earth-geojson (mirrors Natural Earth public-
10// domain data as GeoJSON). Run at Docker build time so the produced files
11// are baked into the image — no runtime third-party calls.
12//
13// Run with `bun run build:maps`.
14
15import { mkdir, writeFile } from "node:fs/promises";
16import { resolve, dirname } from "node:path";
17import { fileURLToPath } from "node:url";
18import { topology } from "topojson-server";
19
20const __dirname = dirname(fileURLToPath(import.meta.url));
21const OUT_DIR = resolve(__dirname, "../../static_maps");
22
23// 110m for the always-on world view (small, ~30 KB topojson). 10m for
24// admin-1 because the 50m and 110m bundles only ship four big countries —
25// 10m is the only Natural Earth tier with full per-country admin-1 coverage.
26const BASE = "https://raw.githubusercontent.com/martynafford/natural-earth-geojson/master";
27const ADMIN0_URL = `${BASE}/110m/cultural/ne_110m_admin_0_countries.json`;
28const ADMIN1_URL = `${BASE}/10m/cultural/ne_10m_admin_1_states_provinces.json`;
29
30// 1e5 keeps coastlines smooth at typical screen sizes while still cutting
31// file size by ~70% versus raw GeoJSON. d3-geo handles the dequantization.
32const QUANTIZATION = 1e5;
33
34async function fetchJson(url) {
35 process.stdout.write(` GET ${url}\n`);
36 const res = await fetch(url);
37 if (!res.ok) throw new Error(`${res.status} ${res.statusText} - ${url}`);
38 return res.json();
39}
40
41// Natural Earth has a long-running bug where France ("FRA") and Norway
42// ("NOR") get ISO_A2 = "-99" because of an EU/Schengen dispute baked into
43// the source. Map them back from ISO_A3 — every other country with a real
44// ISO_A2 ships it cleanly.
45const A3_TO_A2_OVERRIDES = {
46 FRA: "FR",
47 NOR: "NO",
48};
49
50function normalizeCountryCode(props) {
51 const code = props.ISO_A2 ?? props.iso_a2;
52 if (code && code !== "-99" && code !== -99) return code;
53 const eh = props.ISO_A2_EH ?? props.iso_a2_eh;
54 if (eh && eh !== "-99" && eh !== -99) return eh;
55 // ISO_A3 is also "-99" for the same disputed records, so fall back to
56 // ADM0_A3 which Natural Earth always populates.
57 const a3 = props.ADM0_A3 ?? props.adm0_a3 ?? props.ISO_A3 ?? props.iso_a3;
58 if (a3 && a3 !== "-99" && A3_TO_A2_OVERRIDES[a3]) return A3_TO_A2_OVERRIDES[a3];
59 return null;
60}
61
62function trimCountryProps(feature) {
63 // World-map features only need a name and ISO code on the client; drop
64 // the other ~80 Natural Earth fields to keep the payload small.
65 const p = feature.properties || {};
66 const iso = normalizeCountryCode(p);
67 return {
68 ...feature,
69 id: iso,
70 properties: {
71 iso: iso,
72 name: p.NAME ?? p.ADMIN ?? p.name ?? "",
73 },
74 };
75}
76
77function trimAdmin1Props(feature) {
78 const p = feature.properties || {};
79 // Natural Earth's `name` is the local-language form (e.g. "Bayern"),
80 // while DB-IP / MaxMind return the English form ("Bavaria"). Keep both
81 // so the runtime lookup can match either.
82 return {
83 ...feature,
84 properties: {
85 iso_3166_2: p.iso_3166_2 ?? "",
86 postal: p.postal ?? "",
87 name: p.name ?? "",
88 name_alt: p.name_alt ?? "",
89 },
90 };
91}
92
93async function buildWorld(admin0) {
94 const features = admin0.features
95 .map(trimCountryProps)
96 .filter((f) => f.id);
97 const topo = topology({ countries: { type: "FeatureCollection", features } }, QUANTIZATION);
98 const path = resolve(OUT_DIR, "world.json");
99 await writeFile(path, JSON.stringify(topo));
100 return { path, count: features.length, bytes: JSON.stringify(topo).length };
101}
102
103async function buildAdmin1(admin1) {
104 const byCountry = new Map();
105 for (const f of admin1.features) {
106 const iso = normalizeCountryCode(f.properties || {});
107 if (!iso) continue;
108 if (!byCountry.has(iso)) byCountry.set(iso, []);
109 byCountry.get(iso).push(trimAdmin1Props(f));
110 }
111
112 await mkdir(resolve(OUT_DIR, "admin1"), { recursive: true });
113
114 let totalBytes = 0;
115 for (const [iso, features] of byCountry) {
116 const topo = topology({ regions: { type: "FeatureCollection", features } }, QUANTIZATION);
117 const json = JSON.stringify(topo);
118 await writeFile(resolve(OUT_DIR, "admin1", `${iso}.json`), json);
119 totalBytes += json.length;
120 }
121 return { count: byCountry.size, bytes: totalBytes };
122}
123
124async function main() {
125 await mkdir(OUT_DIR, { recursive: true });
126
127 console.log("Downloading Natural Earth source data...");
128 const [admin0, admin1] = await Promise.all([
129 fetchJson(ADMIN0_URL),
130 fetchJson(ADMIN1_URL),
131 ]);
132
133 console.log("Building world topology...");
134 const world = await buildWorld(admin0);
135 console.log(` ${world.count} countries -> ${world.path} (${(world.bytes / 1024).toFixed(1)} KB)`);
136
137 console.log("Building per-country admin-1 topologies...");
138 const a1 = await buildAdmin1(admin1);
139 console.log(` ${a1.count} country files (${(a1.bytes / 1024).toFixed(1)} KB total)`);
140}
141
142main().catch((err) => {
143 console.error(err);
144 process.exit(1);
145});