repos
/ orchard main

orchard

mirror

Every 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

8.2 KB · 254 lines · JavaScript Raw History
  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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
253}