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// World choropleth that drills down to admin-1 on click, on d3-geo and
2// topojson-client. Country shapes are baked into the image and the per-country
3// admin-1 topojson is fetched on click.
4
5import { geoNaturalEarth1, geoMercator, geoAlbersUsa, geoPath } from "d3-geo";
6import { scaleLinear } from "d3-scale";
7import { select } from "d3-selection";
8import { feature } from "topojson-client";
9
10const STATIC_BASE = "/static_maps";
11const WORLD_URL = `${STATIC_BASE}/world.json`;
12const ADMIN1_URL = (iso) => `${STATIC_BASE}/admin1/${iso}.json`;
13
14// A sequential ramp, so one hue from light to dark and no second colour in it.
15// The floor is where it is because a country with one session still has to be
16// distinguishable from a country with none.
17const FILL_LOW = "rgba(87, 179, 120, 0.22)";
18const FILL_HIGH = "rgba(87, 179, 120, 0.95)";
19const FILL_DEFAULT = "rgba(107, 158, 120, 0.10)";
20const STROKE = "rgba(107, 158, 120, 0.32)";
21
22// Selection is a status, not a step on the ramp, which is why it is the one
23// warm colour on the map.
24const STROKE_HIGHLIGHT = "rgba(216, 168, 62, 0.75)";
25const FILL_HIGHLIGHT = "#d8a83e";
26
27document.addEventListener("DOMContentLoaded", () => {
28 const root = document.getElementById("map");
29 if (!root) return;
30
31 const byCountry = readJsonScript("map-session-starts-by-country") || {};
32 const byCountryRegion = readJsonScript("map-session-starts-by-country-region") || {};
33 const titleEl = document.getElementById("map-title");
34 const backEl = document.getElementById("map-back");
35
36 const tooltip = createTooltip(root);
37
38 const admin1Cache = new Map();
39 let worldData = null;
40
41 const state = { view: "world", country: null };
42
43 fetch(WORLD_URL)
44 .then((r) => {
45 if (!r.ok) throw new Error(`${WORLD_URL} returned HTTP ${r.status}`);
46 return r.json();
47 })
48 .then((topo) => {
49 worldData = topo;
50 renderWorld();
51 })
52 .catch((err) => {
53 showFallback(`map unavailable: ${err.message}`);
54 });
55
56 function showFallback(message) {
57 root.querySelectorAll("svg").forEach((s) => s.remove());
58 let fallback = root.querySelector(".map-fallback");
59 if (!fallback) {
60 fallback = document.createElement("div");
61 fallback.className = "map-fallback";
62 Object.assign(fallback.style, { padding: "1rem", color: "#ddd7cd", fontSize: "12px" });
63 root.appendChild(fallback);
64 }
65 fallback.textContent = message;
66 }
67
68 backEl.addEventListener("click", () => {
69 state.view = "world";
70 state.country = null;
71 titleEl.textContent = "sessions · world";
72 backEl.hidden = true;
73 renderWorld();
74 });
75
76 function renderWorld() {
77 const countries = feature(worldData, worldData.objects.countries);
78 const max = Math.max(0, ...Object.values(byCountry));
79 const color = scaleLinear().domain([0, max || 1]).range([FILL_LOW, FILL_HIGH]);
80
81 drawMap({
82 features: countries.features,
83 projection: geoNaturalEarth1(),
84 fillFor: (f) => {
85 const count = byCountry[f.properties.iso] || 0;
86 return count ? color(count) : FILL_DEFAULT;
87 },
88 labelFor: (f) => f.properties.name,
89 countFor: (f) => byCountry[f.properties.iso] || 0,
90 onClick: (f) => {
91 if (f.properties.iso) drillDown(f.properties.iso, f.properties.name);
92 },
93 clickable: (f) => Boolean(byCountryRegion[f.properties.iso]),
94 });
95 }
96
97 async function drillDown(iso, name) {
98 titleEl.textContent = `sessions · ${name.toLowerCase()}`;
99 backEl.hidden = false;
100 state.view = "country";
101 state.country = iso;
102
103 let topo = admin1Cache.get(iso);
104 if (!topo) {
105 try {
106 const res = await fetch(ADMIN1_URL(iso));
107 if (!res.ok) throw new Error(`HTTP ${res.status}`);
108 topo = await res.json();
109 admin1Cache.set(iso, topo);
110 } catch (err) {
111 showFallback(`no detail map for ${name}`);
112 return;
113 }
114 }
115
116 const regions = feature(topo, topo.objects.regions);
117 const counts = byCountryRegion[iso] || {};
118 const max = Math.max(0, ...Object.values(counts));
119 const color = scaleLinear().domain([0, max || 1]).range([FILL_LOW, FILL_HIGH]);
120
121 drawMap({
122 features: regions.features,
123 // geoAlbersUsa insets Alaska and Hawaii so they don't overwhelm the
124 // viewport. geoMercator reads fine for everywhere else.
125 projection: iso === "US" ? geoAlbersUsa() : geoMercator(),
126 fillFor: (f) => {
127 const count = lookupRegionCount(counts, f.properties);
128 return count ? color(count) : FILL_DEFAULT;
129 },
130 labelFor: (f) => f.properties.name,
131 countFor: (f) => lookupRegionCount(counts, f.properties),
132 onClick: () => {},
133 clickable: () => false,
134 });
135 }
136
137 function drawMap({ features: feats, projection, fillFor, labelFor, countFor, onClick, clickable }) {
138 // The tooltip element is left in place, so its handlers and its position
139 // survive a redraw.
140 root.querySelectorAll("svg, .map-fallback").forEach((el) => el.remove());
141 const { width, height } = root.getBoundingClientRect();
142 const w = Math.max(width, 320);
143 const h = Math.max(height, 320);
144
145 projection.fitSize([w, h], { type: "FeatureCollection", features: feats });
146 const path = geoPath(projection);
147
148 const svg = select(root)
149 .append("svg")
150 .attr("viewBox", `0 0 ${w} ${h}`)
151 .attr("preserveAspectRatio", "xMidYMid meet")
152 .style("width", "100%")
153 .style("height", "100%")
154 .style("display", "block");
155
156 svg
157 .append("g")
158 .selectAll("path")
159 .data(feats)
160 .join("path")
161 .attr("d", path)
162 .attr("fill", fillFor)
163 .attr("stroke", STROKE)
164 .attr("stroke-width", 0.6)
165 .style("cursor", (f) => (clickable(f) ? "pointer" : "default"))
166 .on("mouseenter", function (event, f) {
167 const count = countFor(f);
168 select(this).attr("fill", FILL_HIGHLIGHT).attr("stroke", STROKE_HIGHLIGHT);
169 tooltip.show(labelFor(f), count, event);
170 })
171 .on("mousemove", (event) => tooltip.move(event))
172 .on("mouseleave", function (event, f) {
173 select(this).attr("fill", fillFor(f)).attr("stroke", STROKE);
174 tooltip.hide();
175 })
176 .on("click", (_event, f) => onClick(f));
177 }
178});
179
180function readJsonScript(id) {
181 const el = document.getElementById(id);
182 if (!el) return null;
183 try {
184 return JSON.parse(el.textContent);
185 } catch {
186 return null;
187 }
188}
189
190function lookupRegionCount(counts, props) {
191 // GeoIP backends report a region as a subdivision code ("CA"), an English
192 // name ("California"), a local name ("Bayern") or an iso_3166_2 ("US-CA"),
193 // and vary row to row, so every Natural Earth alias gets a try.
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: "#1c1a15",
217 border: "1px solid rgba(107, 158, 120, 0.5)",
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}