heartwood every commit a ring

Replace US-only datamap with world map and DB-IP GeoIP

de5472ad by Isaac Bythewood · 15 days ago

Replace US-only datamap with world map and DB-IP GeoIP

Drop datamaps for d3-geo + topojson-client. World choropleth with click-
through to admin-1 (states/provinces) per country, lazy-fetched from
Natural Earth topojson built into the image. Add refresh_geoip command
that pulls DB-IP City Lite (CC-BY-4.0, no signup) on container start
with a monthly host cron for refresh.
modified .dockerignore
@@ -3,4 +3,5 @@/node_modules/db.sqlite3/analytics/static/analytics/static_maps/.env
modified .gitignore
@@ -5,4 +5,5 @@ __pycache__//db.sqlite3/db.mmdb/analytics/static/analytics/static_maps/.env
modified Dockerfile
@@ -22,6 +22,7 @@ COPY . .ENV PATH="/app/.venv/bin:/app/node_modules/.bin:$PATH"RUN bun run build && \    bun run build:maps && \    uv run python manage.py collectstatic --noinputRUN addgroup -S -g 1000 app && \
modified README.md
@@ -95,24 +95,31 @@ from ourselves.## User location dataI'm unsure how I want to handle user location data at the moment. I'm not reallyinterested in someone's personal location but I do like to know where peopleare coming from region wise. This helps me know if I need to add translationsto my projects or if I need to add maybe a CDN/caching/server to a new region.For that reason I've added a simple way to enable or disable location data. Idon't want to store user IPs so location data isn't retroactive. If you want toenable IP address lookups you can download a free or paid one from MaxMind on[maxmind.com](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data).Once you get a database drop it into the `data` directory on your server andname it `db.mmdb`. Note that we are only using the binary database, not theCSV database.Once added then we'll automatically start recording location data but leave outthe IP address and any directly identifiable information.You can configure the database path in settings.I'm not interested in someone's personal location but I do like to know wherepeople are coming from region wise. This helps me know if I need to addtranslations to my projects or if I need to add a CDN/caching/server to a newregion. We don't store user IPs so location data isn't retroactive.The dashboard ships with a `refresh_geoip` management command that downloadsthe [DB-IP City Lite](https://db-ip.com/db/download/ip-to-city-lite) database(CC-BY-4.0, no signup, MaxMind-compatible MMDB format) into `GEOIP_PATH`. Itruns automatically on container start; if the file already exists and is lessthan 30 days old it skips the download.To keep it fresh, add a host cron entry on the server that re-runs it monthly:```cron# /etc/crontabs/root  — refresh GeoIP on the 4th of each month0 3 4 * * docker exec analytics_web python manage.py refresh_geoip --force```The 4th gives DB-IP a few days to publish their monthly build (1st of themonth). Failures are non-fatal — the collector silently skips GeoIPenrichment if the database is missing.If you'd rather use a different MMDB (MaxMind GeoLite2, IPLocate, etc.) justdrop it at `GEOIP_PATH` (defaults to `/data/db.mmdb` in production) anddisable the cron — any MaxMind-format database works.## Backups
added analytics/scripts/build_maps.js
@@ -0,0 +1,145 @@// Build static map assets from Natural Earth.//// Downloads admin-0 (countries) and admin-1 (states / provinces) GeoJSON,// converts to TopoJSON with quantization, and writes:////   analytics/static_maps/world.json            - all countries, keyed by ISO_A2//   analytics/static_maps/admin1/{ISO_A2}.json  - one file per country//// Source: martynafford/natural-earth-geojson (mirrors Natural Earth public-// domain data as GeoJSON). Run at Docker build time so the produced files// are baked into the image — no runtime third-party calls.//// Run with `bun run build:maps`.import { mkdir, writeFile } from "node:fs/promises";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";import { topology } from "topojson-server";const __dirname = dirname(fileURLToPath(import.meta.url));const OUT_DIR = resolve(__dirname, "../static_maps");// 110m for the always-on world view (small, ~30 KB topojson). 10m for// admin-1 because the 50m and 110m bundles only ship four big countries —// 10m is the only Natural Earth tier with full per-country admin-1 coverage.const BASE = "https://raw.githubusercontent.com/martynafford/natural-earth-geojson/master";const ADMIN0_URL = `${BASE}/110m/cultural/ne_110m_admin_0_countries.json`;const ADMIN1_URL = `${BASE}/10m/cultural/ne_10m_admin_1_states_provinces.json`;// 1e5 keeps coastlines smooth at typical screen sizes while still cutting// file size by ~70% versus raw GeoJSON. d3-geo handles the dequantization.const QUANTIZATION = 1e5;async function fetchJson(url) {  process.stdout.write(`  GET ${url}\n`);  const res = await fetch(url);  if (!res.ok) throw new Error(`${res.status} ${res.statusText} - ${url}`);  return res.json();}// Natural Earth has a long-running bug where France ("FRA") and Norway// ("NOR") get ISO_A2 = "-99" because of an EU/Schengen dispute baked into// the source. Map them back from ISO_A3 — every other country with a real// ISO_A2 ships it cleanly.const A3_TO_A2_OVERRIDES = {  FRA: "FR",  NOR: "NO",};function normalizeCountryCode(props) {  const code = props.ISO_A2 ?? props.iso_a2;  if (code && code !== "-99" && code !== -99) return code;  const eh = props.ISO_A2_EH ?? props.iso_a2_eh;  if (eh && eh !== "-99" && eh !== -99) return eh;  // ISO_A3 is also "-99" for the same disputed records, so fall back to  // ADM0_A3 which Natural Earth always populates.  const a3 = props.ADM0_A3 ?? props.adm0_a3 ?? props.ISO_A3 ?? props.iso_a3;  if (a3 && a3 !== "-99" && A3_TO_A2_OVERRIDES[a3]) return A3_TO_A2_OVERRIDES[a3];  return null;}function trimCountryProps(feature) {  // World-map features only need a name and ISO code on the client; drop  // the other ~80 Natural Earth fields to keep the payload small.  const p = feature.properties || {};  const iso = normalizeCountryCode(p);  return {    ...feature,    id: iso,    properties: {      iso: iso,      name: p.NAME ?? p.ADMIN ?? p.name ?? "",    },  };}function trimAdmin1Props(feature) {  const p = feature.properties || {};  // Natural Earth's `name` is the local-language form (e.g. "Bayern"),  // while DB-IP / MaxMind return the English form ("Bavaria"). Keep both  // so the runtime lookup can match either.  return {    ...feature,    properties: {      iso_3166_2: p.iso_3166_2 ?? "",      postal: p.postal ?? "",      name: p.name ?? "",      name_alt: p.name_alt ?? "",    },  };}async function buildWorld(admin0) {  const features = admin0.features    .map(trimCountryProps)    .filter((f) => f.id);  const topo = topology({ countries: { type: "FeatureCollection", features } }, QUANTIZATION);  const path = resolve(OUT_DIR, "world.json");  await writeFile(path, JSON.stringify(topo));  return { path, count: features.length, bytes: JSON.stringify(topo).length };}async function buildAdmin1(admin1) {  const byCountry = new Map();  for (const f of admin1.features) {    const iso = normalizeCountryCode(f.properties || {});    if (!iso) continue;    if (!byCountry.has(iso)) byCountry.set(iso, []);    byCountry.get(iso).push(trimAdmin1Props(f));  }  await mkdir(resolve(OUT_DIR, "admin1"), { recursive: true });  let totalBytes = 0;  for (const [iso, features] of byCountry) {    const topo = topology({ regions: { type: "FeatureCollection", features } }, QUANTIZATION);    const json = JSON.stringify(topo);    await writeFile(resolve(OUT_DIR, "admin1", `${iso}.json`), json);    totalBytes += json.length;  }  return { count: byCountry.size, bytes: totalBytes };}async function main() {  await mkdir(OUT_DIR, { recursive: true });  console.log("Downloading Natural Earth source data...");  const [admin0, admin1] = await Promise.all([    fetchJson(ADMIN0_URL),    fetchJson(ADMIN1_URL),  ]);  console.log("Building world topology...");  const world = await buildWorld(admin0);  console.log(`  ${world.count} countries -> ${world.path} (${(world.bytes / 1024).toFixed(1)} KB)`);  console.log("Building per-country admin-1 topologies...");  const a1 = await buildAdmin1(admin1);  console.log(`  ${a1.count} country files (${(a1.bytes / 1024).toFixed(1)} KB total)`);}main().catch((err) => {  console.error(err);  process.exit(1);});
modified analytics/settings/__init__.py
@@ -89,7 +89,10 @@ USE_THOUSAND_SEPARATOR = TrueSTATIC_URL = 'static/'STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"STATICFILES_DIRS = (BASE_DIR / "analytics/static",)STATICFILES_DIRS = (    BASE_DIR / "analytics/static",    BASE_DIR / "analytics/static_maps",)STATIC_ROOT = BASE_DIR / "static"
modified analytics/static_src/styles/base.scss
@@ -861,24 +861,33 @@ footer {  }}// ── Datamap tweaks ──// Datamaps renders an SVG; force its container styling to feel like a panel// instead of Bootstrap's default .bg-light island.// ── World map ──// d3-geo renders an SVG inside #map. Tooltip styling lives inline in// property_map.js since it needs runtime positioning anyway.#datamap {#map {  position: relative;  svg path {    stroke: rgba(107, 158, 120, 0.18) !important;    transition: fill 80ms ease, stroke 80ms ease;  }}  .datamaps-hoverover {    background: #13120e !important;    border: 1px solid $green-border !important;    color: $gray-200 !important;    font-family: $font-family-monospace !important;    font-size: 0.75rem !important;    padding: 0.4rem 0.6rem !important;    border-radius: $border-radius !important;.chart-panel-action {  background: transparent;  border: 1px solid rgba(107, 158, 120, 0.25);  color: rgba(221, 215, 205, 0.7);  font-family: $font-family-monospace;  font-size: 0.62rem;  letter-spacing: 0.08em;  text-transform: uppercase;  padding: 0.25rem 0.55rem;  border-radius: $border-radius;  cursor: pointer;  transition: color 80ms, border-color 80ms;  &:hover {    color: #c9a84c;    border-color: rgba(201, 168, 76, 0.5);  }}
modified analytics/templates/base.html
@@ -135,7 +135,7 @@    <div class="container">      <div class="row align-items-center">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start order-1 order-sm-0">          <small>&copy; {% now 'Y' %} Isaac Bythewood · Some rights reserved</small>          <small>&copy; {% now 'Y' %} Isaac Bythewood · Some rights reserved · <a href="https://db-ip.com" class="link-footer" target="_blank" rel="noopener">IP Geolocation by DB-IP</a></small>        </div>        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end py-3 py-sm-0">          <a href="https://github.com/overshard/analytics" target="_blank" class="footer-bar-link" aria-label="GitHub">
modified bun.lock
@@ -8,14 +8,16 @@        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "chart.js": "^4.5.1",        "d3": "^7.9.0",        "d3-geo": "^3.1.1",        "d3-scale": "^4.0.2",        "datamaps": "^0.5.10",        "d3-selection": "^3.0.0",        "d3-zoom": "^3.0.0",        "js-cookie": "^3.0.1",        "topojson": "^3.0.2",        "topojson-client": "^3.1.0",      },      "devDependencies": {        "sass": "^1.97.3",        "topojson-server": "^3",        "vite": "^6.3.1",      },    },
@@ -157,88 +159,36 @@    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],    "@types/d3": ["@types/d3@3.5.38", "", {}, "sha512-O/gRkjWULp3xVX8K85V0H3tsSGole0WYt77KVpGZO2xTGLuVFuvE6JIsIli3fvFHCYBhGFn/8OHEEyMYF+QehA=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],    "acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],    "bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="],    "brfs": ["brfs@1.6.1", "", { "dependencies": { "quote-stream": "^1.0.1", "resolve": "^1.1.5", "static-module": "^2.2.0", "through2": "^2.0.0" }, "bin": { "brfs": "bin/cmd.js" } }, "sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ=="],    "buffer-equal": ["buffer-equal@0.0.1", "", {}, "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA=="],    "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],    "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],    "concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],    "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],    "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],    "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],    "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],    "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],    "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],    "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],    "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],    "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],    "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],    "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],    "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],    "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],    "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],    "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],    "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],    "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],    "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],    "d3-geo-projection": ["d3-geo-projection@0.2.16", "", { "dependencies": { "brfs": "^1.3.0" } }, "sha512-NB4/NRMnfJnpodvRbNY/nOzuoU17P229ASYf2l1GwjZyfD7l5aIuMylDMbIBF4y42BGZZvGdUwFW8iFM/5UBzg=="],    "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],    "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],    "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],    "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],    "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],    "d3-queue": ["d3-queue@2.0.3", "", {}, "sha512-ejbdHqZYEmk9ns/ljSbEcD6VRiuNwAkZMdFf6rsUb3vHROK5iMFd8xewDQnUVr6m/ba2BG63KmR/LySfsluxbg=="],    "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],    "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],    "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],    "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],    "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],    "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],    "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
@@ -249,176 +199,48 @@    "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],    "datamaps": ["datamaps@0.5.10", "", { "dependencies": { "@types/d3": "3.5.38", "d3": "^3.5.6", "topojson": "^1.6.19" } }, "sha512-FU9J8oXQlGuwALvf3LVno/jKSLiK4uTPcbrSfIRcVBwXCZ7I2KMSmj8kqW4rvBvSbd7Sk5oBxy4PTjSVq+XrMw=="],    "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],    "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],    "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],    "escodegen": ["escodegen@1.9.1", "", { "dependencies": { "esprima": "^3.1.3", "estraverse": "^4.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "./bin/esgenerate.js", "escodegen": "./bin/escodegen.js" } }, "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q=="],    "esprima": ["esprima@3.1.3", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-AWwVMNxwhN8+NIPQzAQZCm7RkLC4RbM3B1OobMuyp3i+w73X57KCKaVIxaRZb+DYCojq7rspo+fmuQfAboyhFg=="],    "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],    "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],    "falafel": ["falafel@2.2.5", "", { "dependencies": { "acorn": "^7.1.1", "isarray": "^2.0.1" } }, "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ=="],    "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],    "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],    "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],    "has": ["has@1.0.4", "", {}, "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ=="],    "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],    "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],    "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],    "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],    "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],    "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],    "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],    "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],    "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],    "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],    "levn": ["levn@0.3.0", "", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="],    "magic-string": ["magic-string@0.22.5", "", { "dependencies": { "vlq": "^0.2.2" } }, "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w=="],    "merge-source-map": ["merge-source-map@1.0.4", "", { "dependencies": { "source-map": "^0.5.6" } }, "sha512-PGSmS0kfnTnMJCzJ16BLLCEe6oeYCamKFFdQKshi4BmM6FUwipjVOcBFGxqtQtirtAG4iZvHlqST9CpZKqlRjA=="],    "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],    "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],    "object-inspect": ["object-inspect@1.4.1", "", {}, "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw=="],    "optimist": ["optimist@0.3.7", "", { "dependencies": { "wordwrap": "~0.0.2" } }, "sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ=="],    "optionator": ["optionator@0.8.3", "", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="],    "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],    "prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],    "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],    "quote-stream": ["quote-stream@1.0.2", "", { "dependencies": { "buffer-equal": "0.0.1", "minimist": "^1.1.3", "through2": "^2.0.0" }, "bin": { "quote-stream": "bin/cmd.js" } }, "sha512-kKr2uQ2AokadPjvTyKJQad9xELbZwYzWlNfI3Uz2j/ib5u6H9lDP7fUUR//rMycd0gv4Z5P1qXMfXR8YpIxrjQ=="],    "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],    "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],    "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],    "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],    "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],    "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],    "shallow-copy": ["shallow-copy@0.0.1", "", {}, "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw=="],    "shapefile": ["shapefile@0.3.1", "", { "dependencies": { "d3-queue": "1", "iconv-lite": "0.2", "optimist": "0.3" }, "bin": { "dbfcat": "./bin/dbfcat", "shpcat": "./bin/shpcat", "shp2json": "./bin/shp2json" } }, "sha512-BZoPvnq4ULce0pyKiZUU4D8CdPl0Z1fpE73AeCkwyMbD2hpUeVA0s7jIE/wX8uWNruVeJV6e+rznPHBwuH5J6g=="],    "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],    "static-eval": ["static-eval@2.1.1", "", { "dependencies": { "escodegen": "^2.1.0" } }, "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA=="],    "static-module": ["static-module@2.2.5", "", { "dependencies": { "concat-stream": "~1.6.0", "convert-source-map": "^1.5.1", "duplexer2": "~0.1.4", "escodegen": "~1.9.0", "falafel": "^2.1.0", "has": "^1.0.1", "magic-string": "^0.22.4", "merge-source-map": "1.0.4", "object-inspect": "~1.4.0", "quote-stream": "~1.0.2", "readable-stream": "~2.3.3", "shallow-copy": "~0.0.1", "static-eval": "^2.0.0", "through2": "~2.0.3" } }, "sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ=="],    "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],    "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],    "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],    "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],    "topojson": ["topojson@3.0.2", "", { "dependencies": { "topojson-client": "3.0.0", "topojson-server": "3.0.0", "topojson-simplify": "3.0.2" }, "bin": { "geo2topo": "node_modules/topojson-server/bin/geo2topo", "toposimplify": "node_modules/topojson-simplify/bin/toposimplify", "topo2geo": "node_modules/topojson-client/bin/topo2geo", "topomerge": "node_modules/topojson-client/bin/topomerge", "topoquantize": "node_modules/topojson-client/bin/topoquantize" } }, "sha512-u3zeuL6WEVL0dmsRn7uHZKc4Ao4gpW3sORUv+N3ezLTvY3JdCuyg0hvpWiIfFw8p/JwVN++SvAsFgcFEeR15rQ=="],    "topojson-client": ["topojson-client@3.0.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-2phZ98wg/iKvsWxbB6JQcq0/N0f+sRx8ZogdvjCg+CjaJdmV0knP0OQwK5XbgnytAPx5lPZk41kiWpgH2w9FHg=="],    "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],    "topojson-server": ["topojson-server@3.0.0", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-UhhwQk4e2+lwhAVYkja3J5nQHQmKwORDuIQPkMnFFZFcLqWKLQWI3u7fZWtNIXTElBjTYdBUL1kzi1+oS/qDQw=="],    "topojson-simplify": ["topojson-simplify@3.0.2", "", { "dependencies": { "commander": "2", "topojson-client": "3" }, "bin": { "toposimplify": "bin/toposimplify" } }, "sha512-gyYSVRt4jO/0RJXKZQPzTDQRWV+D/nOfiljNUv0HBXslFLtq3yxRHrl7jbrjdbda5Ytdr7M8BZUI4OxU7tnbRQ=="],    "type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],    "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],    "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],    "vlq": ["vlq@0.2.3", "", {}, "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow=="],    "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],    "wordwrap": ["wordwrap@0.0.3", "", {}, "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw=="],    "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],    "datamaps/d3": ["d3@3.5.17", "", {}, "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg=="],    "datamaps/topojson": ["topojson@1.6.27", "", { "dependencies": { "d3": "3", "d3-geo-projection": "0.2", "d3-queue": "2", "optimist": "0.3", "rw": "1", "shapefile": "0.3" }, "bin": { "topojson": "./bin/topojson", "topojson-geojson": "./bin/topojson-geojson", "topojson-group": "./bin/topojson-group", "topojson-merge": "./bin/topojson-merge", "topojson-svg": "./bin/topojson-svg" } }, "sha512-JLFtrhClUH/k/yvsiCXqcWcXaOfO3DgFvHnYb+gS2xlDbjbvkKh6YB1CPilmEV++tH33xw6wCxoYA5g6YLZw/Q=="],    "merge-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],    "readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],    "shapefile/d3-queue": ["d3-queue@1.2.3", "", {}, "sha512-m6KtxX4V5pmVf1PqhH4SkQVMshSJfyCLM2vf2oFPi9FWFVT3+rtbCGerk766b/JXymHQDU3oqXHaZoiQ/e8yUQ=="],    "shapefile/iconv-lite": ["iconv-lite@0.2.11", "", {}, "sha512-KhmFWgaQZY83Cbhi+ADInoUQ8Etn6BG5fikM9syeOjQltvR45h7cRKJ/9uvQEuD61I3Uju77yYce0/LhKVClQw=="],    "static-eval/escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],    "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "topojson-simplify/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "static-eval/escodegen/esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],    "static-eval/escodegen/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],  }}
modified collector/views.py
@@ -82,7 +82,11 @@ def collect(request):                g_data = g.city(ip)                if g_data:                    event_obj.data['country'] = g_data['country_code']                    event_obj.data['region'] = g_data['region']                    # Some MMDB providers (e.g. DB-IP free) don't populate the                    # ISO subdivision code — only the full name. Prefer the                    # name so we get a value either way; the world map's                    # region lookup matches against both forms.                    event_obj.data['region'] = g_data.get('region_name') or g_data.get('region')                    event_obj.data['city'] = g_data['city']                    event_obj.data['loc'] = [g_data['latitude'], g_data['longitude']]    except (GeoIP2Exception, AddressNotFoundError):
modified docker-compose.yml
@@ -8,7 +8,8 @@ services:    ports:      - "127.0.0.1:${PORT}:${PORT}"    command: >      gunicorn analytics.asgi:application --preload --workers 2 --max-requests 256      sh -c "python manage.py refresh_geoip;      exec gunicorn analytics.asgi:application --preload --workers 2 --max-requests 256      --timeout 30 --bind :${PORT} --worker-class uvicorn.workers.UvicornWorker      --error-logfile - --access-logfile -      --error-logfile - --access-logfile -"    restart: unless-stopped
modified package.json
@@ -3,21 +3,24 @@  "type": "module",  "scripts": {    "dev": "vite build --watch",    "build": "vite build"    "build": "vite build",    "build:maps": "bun analytics/scripts/build_maps.js"  },  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "chart.js": "^4.5.1",    "d3": "^7.9.0",    "d3-geo": "^3.1.1",    "d3-scale": "^4.0.2",    "datamaps": "^0.5.10",    "d3-selection": "^3.0.0",    "d3-zoom": "^3.0.0",    "js-cookie": "^3.0.1",    "topojson": "^3.0.2"    "topojson-client": "^3.1.0"  },  "devDependencies": {    "sass": "^1.97.3",    "topojson-server": "^3",    "vite": "^6.3.1"  }}
modified properties/constants.py
@@ -1,62 +1 @@BUILT_IN_EVENTS = ["session_start", "page_view", "page_leave", "click", "scroll"]US_STATES = {    "Alabama": "AL",    "Alaska": "AK",    "Arizona": "AZ",    "Arkansas": "AR",    "California": "CA",    "Colorado": "CO",    "Connecticut": "CT",    "Delaware": "DE",    "Florida": "FL",    "Georgia": "GA",    "Hawaii": "HI",    "Idaho": "ID",    "Illinois": "IL",    "Indiana": "IN",    "Iowa": "IA",    "Kansas": "KS",    "Kentucky": "KY",    "Louisiana": "LA",    "Maine": "ME",    "Maryland": "MD",    "Massachusetts": "MA",    "Michigan": "MI",    "Minnesota": "MN",    "Mississippi": "MS",    "Missouri": "MO",    "Montana": "MT",    "Nebraska": "NE",    "Nevada": "NV",    "New Hampshire": "NH",    "New Jersey": "NJ",    "New Mexico": "NM",    "New York": "NY",    "North Carolina": "NC",    "North Dakota": "ND",    "Ohio": "OH",    "Oklahoma": "OK",    "Oregon": "OR",    "Pennsylvania": "PA",    "Rhode Island": "RI",    "South Carolina": "SC",    "South Dakota": "SD",    "Tennessee": "TN",    "Texas": "TX",    "Utah": "UT",    "Vermont": "VT",    "Virginia": "VA",    "Washington": "WA",    "West Virginia": "WV",    "Wisconsin": "WI",    "Wyoming": "WY",    "District of Columbia": "DC",    "American Samoa": "AS",    "Guam": "GU",    "Northern Mariana Islands": "MP",    "Puerto Rico": "PR",    "United States Minor Outlying Islands": "UM",    "U.S. Virgin Islands": "VI",}
added properties/management/commands/refresh_geoip.py
@@ -0,0 +1,111 @@"""Download or refresh the DB-IP City Lite GeoIP database.DB-IP publishes a fresh build on the 1st of each month at:    https://download.db-ip.com/free/dbip-city-lite-YYYY-MM.mmdb.gzLicense is CC-BY-4.0 — attribution lives in the dashboard footer. No account,no API key. The mmdb format is MaxMind-compatible so the existing geoip2reader picks it up unchanged.Run on container start (idempotent, skips if the on-disk file is fresh) andagain monthly via host cron."""import gzipimport osimport shutilimport sysimport tempfileimport urllib.errorimport urllib.requestfrom datetime import date, timedeltafrom pathlib import Pathfrom django.conf import settingsfrom django.core.management.base import BaseCommandURL_TEMPLATE = "https://download.db-ip.com/free/dbip-city-lite-{year}-{month:02d}.mmdb.gz"USER_AGENT = "analytics-refresh-geoip/1.0 (+https://github.com/overshard/analytics)"MAX_AGE_DAYS = 30def _candidate_months(today=None):    """    Yield (year, month) tuples to try, newest first.    DB-IP publishes on the 1st but may lag a few hours; if the current month    isn't up yet, fall back to the previous month, and one more before that    in case we're catching up after a long outage.    """    today = today or date.today()    for offset in (0, 1, 2):        d = (today.replace(day=1) - timedelta(days=offset * 28)).replace(day=1)        yield d.year, d.monthclass Command(BaseCommand):    help = "Download (or refresh) the DB-IP City Lite GeoIP database to GEOIP_PATH."    def add_arguments(self, parser):        parser.add_argument(            "--force",            action="store_true",            help="Re-download even if the existing file is younger than 30 days.",        )    def handle(self, *args, **options):        target = Path(getattr(settings, "GEOIP_PATH", "")).resolve()        if not target.parent.exists():            self.stderr.write(f"GEOIP_PATH parent dir does not exist: {target.parent}")            sys.exit(0)        if not options["force"] and target.exists():            age_days = (date.today() - date.fromtimestamp(target.stat().st_mtime)).days            if age_days < MAX_AGE_DAYS:                self.stdout.write(f"GeoIP database is {age_days}d old; skipping refresh.")                return        last_error = None        for year, month in _candidate_months():            url = URL_TEMPLATE.format(year=year, month=month)            try:                self.stdout.write(f"Fetching {url}")                self._download(url, target)                self.stdout.write(self.style.SUCCESS(f"GeoIP database updated at {target}"))                return            except urllib.error.HTTPError as e:                if e.code == 404:                    self.stdout.write(f"  not yet published ({year}-{month:02d})")                    last_error = e                    continue                last_error = e                break            except (urllib.error.URLError, OSError) as e:                last_error = e                break        # Non-fatal: dashboard works without GeoIP, collector silently skips        # enrichment when the file is missing or stale.        self.stderr.write(f"GeoIP refresh failed: {last_error}")        sys.exit(0)    def _download(self, url, target):        request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})        with urllib.request.urlopen(request, timeout=120) as response:            tmp_dir = target.parent            with tempfile.NamedTemporaryFile(                dir=tmp_dir, prefix=".geoip-", suffix=".mmdb", delete=False            ) as tmp:                tmp_path = Path(tmp.name)                try:                    with gzip.GzipFile(fileobj=response) as gz:                        shutil.copyfileobj(gz, tmp)                    tmp.flush()                    os.fsync(tmp.fileno())                except Exception:                    tmp_path.unlink(missing_ok=True)                    raise        os.replace(tmp_path, target)
modified properties/queries.py
@@ -2,7 +2,7 @@ from django.db.models import Avg, Count, FloatField, Qfrom django.db.models.functions import Cast, TruncDatefrom django.utils import timezonefrom .constants import BUILT_IN_EVENTS, US_STATESfrom .constants import BUILT_IN_EVENTSdef human_events(events):
@@ -296,28 +296,37 @@ def page_views_by_utm(events_filtered, field, limit=10):    return _top_by_key(events_filtered, f"data__utm_{field}", limit, event="page_view")def session_starts_by_region(events_filtered, limit=10):def session_starts_by_country(events_filtered):    """Sessions grouped by ISO 3166-1 alpha-2 country code."""    rows = (        events_filtered.filter(event="session_start")        .exclude(data__country__isnull=True)        .values("data__country")        .annotate(count=Count("id"))    )    return {r["data__country"]: r["count"] for r in rows}def session_starts_by_country_region(events_filtered):    """    Sessions grouped first by country, then by region within that country.    Returned shape: {"US": {"CA": 42, "NY": 17}, "DE": {"BY": 9}, ...}.    Used by the world map for click-to-drill-down — the whole tree ships    with the dashboard so no extra request is needed when a country is    selected.    """    rows = (        events_filtered.filter(event="session_start")        .exclude(data__country__isnull=True)        .exclude(data__region__isnull=True)        .values("data__region")        .values("data__country", "data__region")        .annotate(count=Count("id"))        .order_by("-count")[:limit]    )    return [{"label": r["data__region"], "count": r["count"]} for r in rows]def region_map_data(regions):    """Convert a regions list into the datamaps dict keyed by US state code."""    chart = {}    state_codes = set(US_STATES.values())    for r in regions:        label = r["label"]        if label in US_STATES:            chart[US_STATES[label]] = {"numberOfThings": r["count"]}        elif label in state_codes:            chart[label] = {"numberOfThings": r["count"]}    return chart    out = {}    for r in rows:        out.setdefault(r["data__country"], {})[r["data__region"]] = r["count"]    return outdef bot_traffic(events_all, limit=10):
modified properties/static_src/scripts/property_map.js
@@ -1,59 +1,250 @@// World choropleth with click-to-drill-down to admin-1 (states / provinces /// regions). Replaces the abandoned `datamaps` library with vanilla d3-geo +// topojson-client. Country shapes are baked into the image; per-country// admin-1 topojson is lazy-fetched on click.import { geoNaturalEarth1, geoMercator, geoAlbersUsa, geoPath } from "d3-geo";import { scaleLinear } from "d3-scale";import Datamap from "datamaps/dist/datamaps.usa";import { select } from "d3-selection";import { feature } from "topojson-client";const STATIC_BASE = "/static";const WORLD_URL = `${STATIC_BASE}/world.json`;const ADMIN1_URL = (iso) => `${STATIC_BASE}/admin1/${iso}.json`;const FILL_LOW = "rgba(107, 158, 120, 0.12)";const FILL_HIGH = "rgba(125, 184, 140, 0.95)";const FILL_DEFAULT = "rgba(107, 158, 120, 0.06)";const STROKE = "rgba(107, 158, 120, 0.18)";const STROKE_HIGHLIGHT = "rgba(201, 168, 76, 0.6)";const FILL_HIGHLIGHT = "#c9a84c";document.addEventListener("DOMContentLoaded", () => {  const root = document.getElementById("map");  if (!root) return;  const byCountry = readJsonScript("map-session-starts-by-country") || {};  const byCountryRegion = readJsonScript("map-session-starts-by-country-region") || {};  const titleEl = document.getElementById("map-title");  const backEl = document.getElementById("map-back");document.addEventListener("DOMContentLoaded", function () {  const datamapEl = document.getElementById("datamap");  if (!datamapEl) return;  const tooltip = createTooltip(root);  const data = JSON.parse(    document.getElementById("chart-total-session-starts-by-region-data").innerHTML  );  // Cache fetched admin-1 topojson per country so re-clicking is instant.  const admin1Cache = new Map();  let worldData = null;  let max = 0;  for (const region in data) {    if (data[region].numberOfThings > max) {      max = data[region].numberOfThings;  const state = { view: "world", country: null };  fetch(WORLD_URL)    .then((r) => r.json())    .then((topo) => {      worldData = topo;      renderWorld();    })    .catch((err) => {      showFallback(`map unavailable: ${err.message}`);    });  function showFallback(message) {    root.querySelectorAll("svg").forEach((s) => s.remove());    let fallback = root.querySelector(".map-fallback");    if (!fallback) {      fallback = document.createElement("div");      fallback.className = "map-fallback";      Object.assign(fallback.style, { padding: "1rem", color: "#ddd7cd", fontSize: "12px" });      root.appendChild(fallback);    }    fallback.textContent = message;  }  // Warm-earth choropleth: faint green for low volume, bright green for the  // busiest states. Matches the rest of the dashboard palette.  const colorScale = scaleLinear()    .domain([0, max])    .range(["rgba(107, 158, 120, 0.12)", "rgba(125, 184, 140, 0.95)"]);  for (const region in data) {    data[region].fillColor = colorScale(data[region].numberOfThings);  backEl.addEventListener("click", () => {    state.view = "world";    state.country = null;    titleEl.textContent = "sessions · world";    backEl.hidden = true;    renderWorld();  });  function renderWorld() {    const countries = feature(worldData, worldData.objects.countries);    const max = Math.max(0, ...Object.values(byCountry));    const color = scaleLinear().domain([0, max || 1]).range([FILL_LOW, FILL_HIGH]);    drawMap({      features: countries.features,      projection: geoNaturalEarth1(),      fillFor: (f) => {        const count = byCountry[f.properties.iso] || 0;        return count ? color(count) : FILL_DEFAULT;      },      labelFor: (f) => f.properties.name,      countFor: (f) => byCountry[f.properties.iso] || 0,      onClick: (f) => {        if (f.properties.iso) drillDown(f.properties.iso, f.properties.name);      },      clickable: (f) => Boolean(byCountryRegion[f.properties.iso]),    });  }  const datamap = new Datamap({    element: datamapEl,    scope: "usa",    responsive: true,    fills: {      defaultFill: "rgba(107, 158, 120, 0.06)",    },    geographyConfig: {      borderColor: "rgba(107, 158, 120, 0.18)",      borderWidth: 0.6,      highlightFillColor: "#c9a84c",      highlightBorderColor: "rgba(201, 168, 76, 0.6)",      popupTemplate: function (geo, data) {        if (!data || data.numberOfThings == null) return "";        const count = data.numberOfThings;        return (`          <div style="font-family:'Monaspace Argon',ui-monospace,monospace;line-height:1.4;padding:6px 10px;background:#13120e;border:1px solid rgba(107, 158, 120, 0.3);border-radius:4px;font-size:12px;white-space:nowrap;pointer-events:none;color:#ddd7cd;">            <span style="display:block;font-weight:600;color:#ede8e0;-webkit-text-fill-color:#ede8e0;letter-spacing:0.02em;">${geo.properties.name}</span>            <span style="color:#c9a84c;-webkit-text-fill-color:#c9a84c;">${count} session${count === 1 ? "" : "s"}</span>          </div>        `);  async function drillDown(iso, name) {    titleEl.textContent = `sessions · ${name.toLowerCase()}`;    backEl.hidden = false;    state.view = "country";    state.country = iso;    let topo = admin1Cache.get(iso);    if (!topo) {      try {        const res = await fetch(ADMIN1_URL(iso));        if (!res.ok) throw new Error(`HTTP ${res.status}`);        topo = await res.json();        admin1Cache.set(iso, topo);      } catch (err) {        showFallback(`no detail map for ${name}`);        return;      }    }    const regions = feature(topo, topo.objects.regions);    const counts = byCountryRegion[iso] || {};    const max = Math.max(0, ...Object.values(counts));    const color = scaleLinear().domain([0, max || 1]).range([FILL_LOW, FILL_HIGH]);    drawMap({      features: regions.features,      // geoAlbersUsa insets Alaska + Hawaii so they don't overwhelm the      // viewport. geoMercator works fine for most other countries (a bit of      // distortion at high latitudes, but readable).      projection: iso === "US" ? geoAlbersUsa() : geoMercator(),      fillFor: (f) => {        const count = lookupRegionCount(counts, f.properties);        return count ? color(count) : FILL_DEFAULT;      },    },    data: data,  });      labelFor: (f) => f.properties.name,      countFor: (f) => lookupRegionCount(counts, f.properties),      onClick: () => {},      clickable: () => false,    });  }  datamapEl.querySelector("svg").style.display = "block";  function drawMap({ features: feats, projection, fillFor, labelFor, countFor, onClick, clickable }) {    // Only clear the previous SVG and any fallback message — leave the    // tooltip element in place so its event handlers and stable position    // survive across redraws.    root.querySelectorAll("svg, .map-fallback").forEach((el) => el.remove());    const { width, height } = root.getBoundingClientRect();    const w = Math.max(width, 320);    const h = Math.max(height, 320);  window.addEventListener("resize", function () {    datamap.resize();  });    projection.fitSize([w, h], { type: "FeatureCollection", features: feats });    const path = geoPath(projection);    const svg = select(root)      .append("svg")      .attr("viewBox", `0 0 ${w} ${h}`)      .attr("preserveAspectRatio", "xMidYMid meet")      .style("width", "100%")      .style("height", "100%")      .style("display", "block");    svg      .append("g")      .selectAll("path")      .data(feats)      .join("path")      .attr("d", path)      .attr("fill", fillFor)      .attr("stroke", STROKE)      .attr("stroke-width", 0.6)      .style("cursor", (f) => (clickable(f) ? "pointer" : "default"))      .on("mouseenter", function (event, f) {        const count = countFor(f);        select(this).attr("fill", FILL_HIGHLIGHT).attr("stroke", STROKE_HIGHLIGHT);        tooltip.show(labelFor(f), count, event);      })      .on("mousemove", (event) => tooltip.move(event))      .on("mouseleave", function (event, f) {        select(this).attr("fill", fillFor(f)).attr("stroke", STROKE);        tooltip.hide();      })      .on("click", (_event, f) => onClick(f));  }});function readJsonScript(id) {  const el = document.getElementById(id);  if (!el) return null;  try {    return JSON.parse(el.textContent);  } catch {    return null;  }}function lookupRegionCount(counts, props) {  // GeoIP backends report region differently across providers and even  // across rows: it can be the ISO subdivision code ("CA"), the full  // English name ("California" / "Bavaria"), the local-language name  // ("Bayern"), or the iso_3166_2 form ("US-CA"). Try each Natural Earth  // alias until one matches.  const tries = [    props.postal,    props.iso_3166_2,    props.name,    props.name_alt,  ];  for (const key of tries) {    if (key && counts[key] != null) return counts[key];  }  // Some readers store just the suffix of iso_3166_2 (e.g. "CA" not "US-CA").  if (props.iso_3166_2) {    const tail = props.iso_3166_2.split("-")[1];    if (counts[tail] != null) return counts[tail];  }  return 0;}function createTooltip(root) {  const el = document.createElement("div");  Object.assign(el.style, {    position: "absolute",    pointerEvents: "none",    background: "#13120e",    border: "1px solid rgba(107, 158, 120, 0.3)",    borderRadius: "4px",    padding: "6px 10px",    fontFamily: "'Monaspace Argon', ui-monospace, monospace",    fontSize: "12px",    color: "#ddd7cd",    whiteSpace: "nowrap",    lineHeight: "1.4",    transform: "translate(-50%, calc(-100% - 8px))",    opacity: "0",    transition: "opacity 80ms",    zIndex: "10",  });  root.appendChild(el);  return {    show(label, count, event) {      el.innerHTML =        `<span style="display:block;font-weight:600;color:#ede8e0;letter-spacing:0.02em;">${escape(label)}</span>` +        `<span style="color:#c9a84c;">${count} session${count === 1 ? "" : "s"}</span>`;      this.move(event);      el.style.opacity = "1";    },    move(event) {      const rect = root.getBoundingClientRect();      el.style.left = `${event.clientX - rect.left}px`;      el.style.top = `${event.clientY - rect.top}px`;    },    hide() {      el.style.opacity = "0";    },  };}function escape(s) {  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));}
modified properties/templates/properties/property.html
@@ -13,7 +13,8 @@{{ total_events_by_screen_size|json_script:"chart-total-events-by-screen-size-data" }}{{ total_events_by_device|json_script:"chart-total-events-by-device-data" }}{{ total_events_by_platform|json_script:"chart-total-events-by-platform-data" }}{{ total_session_starts_by_region_chart_data|json_script:"chart-total-session-starts-by-region-data" }}{{ session_starts_by_country|json_script:"map-session-starts-by-country" }}{{ session_starts_by_country_region|json_script:"map-session-starts-by-country-region" }}<script type="module" src="{% static 'properties.js' %}"></script>{% endblock %}
@@ -181,11 +182,12 @@        </div>      </div>      <div class="chart-panel">        <div class="chart-panel-header">          <span class="chart-panel-title">sessions · US states</span>        <div class="chart-panel-header chart-panel-header-with-action">          <span class="chart-panel-title" id="map-title">sessions · world</span>          <button type="button" id="map-back" class="chart-panel-action" hidden>← back to world</button>        </div>        <div class="chart-panel-body">          <div id="datamap" style="min-height: 320px;"></div>          <div id="map" style="min-height: 320px; position: relative;"></div>        </div>      </div>    </div>
modified properties/views.py
@@ -130,8 +130,6 @@ def _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, f    )    event_cards.extend(custom_cards)    regions = q.session_starts_by_region(events_filtered, limit=100)    return {        "event_cards": event_cards,        "custom_events": custom_events,
@@ -147,8 +145,8 @@ def _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, f        "total_page_views_by_utm_medium": q.page_views_by_utm(events_filtered, "medium"),        "total_page_views_by_utm_source": q.page_views_by_utm(events_filtered, "source"),        "total_page_views_by_utm_campaign": q.page_views_by_utm(events_filtered, "campaign"),        "total_session_starts_by_region": regions[:10],        "total_session_starts_by_region_chart_data": q.region_map_data(regions),        "session_starts_by_country": q.session_starts_by_country(events_filtered),        "session_starts_by_country_region": q.session_starts_by_country_region(events_filtered),        "bot_traffic": q.bot_traffic(events_all),    }
modified vite.config.js
@@ -1,28 +1,7 @@import { resolve } from "path";import { defineConfig } from "vite";// datamaps pulls in d3 v3, whose top-level IIFE reads globals off `this`// (this.document, this.navigator, etc.). Under ESM/strict `this` is undefined,// crashing at load. Bind the IIFE's `this` to globalThis so those reads work.//// Datamaps itself also has a sloppy-mode bug: `hoverover = ...` is written in// one function without ever being declared, relying on implicit global in// non-strict mode. Under ESM/strict that throws ReferenceError. Hoist a// declaration into the top-level IIFE so the bare assignment resolves.const fixDatamapsStrictMode = {  name: "fix-datamaps-strict-mode",  transform(code, id) {    if (id.includes("datamaps/node_modules/d3/d3.js")) {      return code.replace(/\}\(\);\s*$/, "}.call(globalThis);\n");    }    if (id.match(/datamaps\/dist\/datamaps\.[^/]+\.js$/)) {      return code.replace("var svg;", "var svg, hoverover;");    }  },};export default defineConfig({  plugins: [fixDatamapsStrictMode],  base: "/static/",  build: {    outDir: resolve(__dirname, "analytics/static"),