Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1// World choropleth with click-to-drill-down to admin-1 (states / provinces /
2// regions). Replaces the abandoned `datamaps` library with vanilla d3-geo +
3// topojson-client. Country shapes are baked into the image; per-country
4// admin-1 topojson is lazy-fetched on click.
5
6import { geoNaturalEarth1, geoMercator, geoAlbersUsa, geoPath } from "d3-geo";
7import { scaleLinear } from "d3-scale";
8import { select } from "d3-selection";
9import { feature } from "topojson-client";
10
11const STATIC_BASE = "/static_maps";
12const WORLD_URL = `${STATIC_BASE}/world.json`;
13const ADMIN1_URL = (iso) => `${STATIC_BASE}/admin1/${iso}.json`;
14
15const FILL_LOW = "rgba(107, 158, 120, 0.12)";
16const FILL_HIGH = "rgba(125, 184, 140, 0.95)";
17const FILL_DEFAULT = "rgba(107, 158, 120, 0.06)";
18const STROKE = "rgba(107, 158, 120, 0.18)";
19const STROKE_HIGHLIGHT = "rgba(201, 168, 76, 0.6)";
20const FILL_HIGHLIGHT = "#c9a84c";
21
22document.addEventListener("DOMContentLoaded", () => {
23 const root = document.getElementById("map");
24 if (!root) return;
25
26 const byCountry = readJsonScript("map-session-starts-by-country") || {};
27 const byCountryRegion = readJsonScript("map-session-starts-by-country-region") || {};
28 const titleEl = document.getElementById("map-title");
29 const backEl = document.getElementById("map-back");
30
31 const tooltip = createTooltip(root);
32
33 // Cache fetched admin-1 topojson per country so re-clicking is instant.
34 const admin1Cache = new Map();
35 let worldData = null;
36
37 const state = { view: "world", country: null };
38
39 fetch(WORLD_URL)
40 .then((r) => {
41 if (!r.ok) throw new Error(`${WORLD_URL} returned HTTP ${r.status}`);
42 return r.json();
43 })
44 .then((topo) => {
45 worldData = topo;
46 renderWorld();
47 })
48 .catch((err) => {
49 showFallback(`map unavailable: ${err.message}`);
50 });
51
52 function showFallback(message) {
53 root.querySelectorAll("svg").forEach((s) => s.remove());
54 let fallback = root.querySelector(".map-fallback");
55 if (!fallback) {
56 fallback = document.createElement("div");
57 fallback.className = "map-fallback";
58 Object.assign(fallback.style, { padding: "1rem", color: "#ddd7cd", fontSize: "12px" });
59 root.appendChild(fallback);
60 }
61 fallback.textContent = message;
62 }
63
64 backEl.addEventListener("click", () => {
65 state.view = "world";
66 state.country = null;
67 titleEl.textContent = "sessions · world";
68 backEl.hidden = true;
69 renderWorld();
70 });
71
72 function renderWorld() {
73 const countries = feature(worldData, worldData.objects.countries);
74 const max = Math.max(0, ...Object.values(byCountry));
75 const color = scaleLinear().domain([0, max || 1]).range([FILL_LOW, FILL_HIGH]);
76
77 drawMap({
78 features: countries.features,
79 projection: geoNaturalEarth1(),
80 fillFor: (f) => {
81 const count = byCountry[f.properties.iso] || 0;
82 return count ? color(count) : FILL_DEFAULT;
83 },
84 labelFor: (f) => f.properties.name,
85 countFor: (f) => byCountry[f.properties.iso] || 0,
86 onClick: (f) => {
87 if (f.properties.iso) drillDown(f.properties.iso, f.properties.name);
88 },
89 clickable: (f) => Boolean(byCountryRegion[f.properties.iso]),
90 });
91 }
92
93 async function drillDown(iso, name) {
94 titleEl.textContent = `sessions · ${name.toLowerCase()}`;
95 backEl.hidden = false;
96 state.view = "country";
97 state.country = iso;
98
99 let topo = admin1Cache.get(iso);
100 if (!topo) {
101 try {
102 const res = await fetch(ADMIN1_URL(iso));
103 if (!res.ok) throw new Error(`HTTP ${res.status}`);
104 topo = await res.json();
105 admin1Cache.set(iso, topo);
106 } catch (err) {
107 showFallback(`no detail map for ${name}`);
108 return;
109 }
110 }
111
112 const regions = feature(topo, topo.objects.regions);
113 const counts = byCountryRegion[iso] || {};
114 const max = Math.max(0, ...Object.values(counts));
115 const color = scaleLinear().domain([0, max || 1]).range([FILL_LOW, FILL_HIGH]);
116
117 drawMap({
118 features: regions.features,
119 // geoAlbersUsa insets Alaska + Hawaii so they don't overwhelm the
120 // viewport. geoMercator works fine for most other countries (a bit of
121 // distortion at high latitudes, but readable).
122 projection: iso === "US" ? geoAlbersUsa() : geoMercator(),
123 fillFor: (f) => {
124 const count = lookupRegionCount(counts, f.properties);
125 return count ? color(count) : FILL_DEFAULT;
126 },
127 labelFor: (f) => f.properties.name,
128 countFor: (f) => lookupRegionCount(counts, f.properties),
129 onClick: () => {},
130 clickable: () => false,
131 });
132 }
133
134 function drawMap({ features: feats, projection, fillFor, labelFor, countFor, onClick, clickable }) {
135 // Only clear the previous SVG and any fallback message — leave the
136 // tooltip element in place so its event handlers and stable position
137 // survive across redraws.
138 root.querySelectorAll("svg, .map-fallback").forEach((el) => el.remove());
139 const { width, height } = root.getBoundingClientRect();
140 const w = Math.max(width, 320);
141 const h = Math.max(height, 320);
142
143 projection.fitSize([w, h], { type: "FeatureCollection", features: feats });
144 const path = geoPath(projection);
145
146 const svg = select(root)
147 .append("svg")
148 .attr("viewBox", `0 0 ${w} ${h}`)
149 .attr("preserveAspectRatio", "xMidYMid meet")
150 .style("width", "100%")
151 .style("height", "100%")
152 .style("display", "block");
153
154 svg
155 .append("g")
156 .selectAll("path")
157 .data(feats)
158 .join("path")
159 .attr("d", path)
160 .attr("fill", fillFor)
161 .attr("stroke", STROKE)
162 .attr("stroke-width", 0.6)
163 .style("cursor", (f) => (clickable(f) ? "pointer" : "default"))
164 .on("mouseenter", function (event, f) {
165 const count = countFor(f);
166 select(this).attr("fill", FILL_HIGHLIGHT).attr("stroke", STROKE_HIGHLIGHT);
167 tooltip.show(labelFor(f), count, event);
168 })
169 .on("mousemove", (event) => tooltip.move(event))
170 .on("mouseleave", function (event, f) {
171 select(this).attr("fill", fillFor(f)).attr("stroke", STROKE);
172 tooltip.hide();
173 })
174 .on("click", (_event, f) => onClick(f));
175 }
176});
177
178function readJsonScript(id) {
179 const el = document.getElementById(id);
180 if (!el) return null;
181 try {
182 return JSON.parse(el.textContent);
183 } catch {
184 return null;
185 }
186}
187
188function lookupRegionCount(counts, props) {
189 // GeoIP backends report region differently across providers and even
190 // across rows: it can be the ISO subdivision code ("CA"), the full
191 // English name ("California" / "Bavaria"), the local-language name
192 // ("Bayern"), or the iso_3166_2 form ("US-CA"). Try each Natural Earth
193 // alias until one matches.
194 const tries = [
195 props.postal,
196 props.iso_3166_2,
197 props.name,
198 props.name_alt,
199 ];
200 for (const key of tries) {
201 if (key && counts[key] != null) return counts[key];
202 }
203 // Some readers store just the suffix of iso_3166_2 (e.g. "CA" not "US-CA").
204 if (props.iso_3166_2) {
205 const tail = props.iso_3166_2.split("-")[1];
206 if (counts[tail] != null) return counts[tail];
207 }
208 return 0;
209}
210
211function createTooltip(root) {
212 const el = document.createElement("div");
213 Object.assign(el.style, {
214 position: "absolute",
215 pointerEvents: "none",
216 background: "#13120e",
217 border: "1px solid rgba(107, 158, 120, 0.3)",
218 borderRadius: "4px",
219 padding: "6px 10px",
220 fontFamily: "'Monaspace Argon', ui-monospace, monospace",
221 fontSize: "12px",
222 color: "#ddd7cd",
223 whiteSpace: "nowrap",
224 lineHeight: "1.4",
225 transform: "translate(-50%, calc(-100% - 8px))",
226 opacity: "0",
227 transition: "opacity 80ms",
228 zIndex: "10",
229 });
230 root.appendChild(el);
231
232 return {
233 show(label, count, event) {
234 el.innerHTML =
235 `<span style="display:block;font-weight:600;color:#ede8e0;letter-spacing:0.02em;">${escape(label)}</span>` +
236 `<span style="color:#c9a84c;">${count} session${count === 1 ? "" : "s"}</span>`;
237 this.move(event);
238 el.style.opacity = "1";
239 },
240 move(event) {
241 const rect = root.getBoundingClientRect();
242 el.style.left = `${event.clientX - rect.left}px`;
243 el.style.top = `${event.clientY - rect.top}px`;
244 },
245 hide() {
246 el.style.opacity = "0";
247 },
248 };
249}
250
251function escape(s) {
252 return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
253}