repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

5.1 KB · 146 lines · JavaScript Raw History
  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});