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

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