repos
/ analytics-django master

analytics-django

mirror archived upstream

Self-hostable website analytics on Django: a straightforward collector API, dashboards, a world map, and PDF reports.

analyticsdjangodockerhandcodedpythonself-hostedsqliteviteweb-analytics

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