Skip to content
quarry Artifact [fa4f682862]
Login

Artifact [fa4f682862]

Artifact fa4f6828625e5dc00e06506a00eec5a3ed87c8a6aae5cca070afa7001589ed0f:


// traffic.js: the two things the traffic page cannot do as HTML.
//
// Hovering a chart names the point under the pointer, and the globe
// turns. Everything else on the page is drawn by the server and works
// without this file; the globe's numbers are also the country list
// beside it.
//
// The short windows stream: the server patches the page's body every
// half minute. So nothing here holds on to an element. Hover listens on
// the document and finds its chart at the moment of the event, and the
// globe, which the patch leaves alone, rereads its numbers whenever the
// patch changes them.
(function () {
  "use strict";

  // Charts. Each plot carries its points' text in data-hover, already
  // formatted by the server, so this file never formats a number.
  var hovered = null;
  function parts(plot) {
    var cross = plot.querySelector(":scope > .tr-cross");
    var tip = plot.querySelector(":scope > .tr-tip");
    if (!cross) {
      cross = document.createElement("div");
      cross.className = "tr-cross";
      plot.appendChild(cross);
    }
    if (!tip) {
      tip = document.createElement("div");
      tip.className = "tr-tip";
      tip.setAttribute("role", "status");
      plot.appendChild(tip);
    }
    return [cross, tip];
  }
  function hide(plot) {
    if (!plot) return;
    plot.querySelectorAll(":scope > .tr-cross, :scope > .tr-tip").forEach(function (e) { e.hidden = true; });
  }
  function show(plot, clientX) {
    var data;
    try { data = JSON.parse(plot.getAttribute("data-hover")); } catch (e) { return; }
    var n = data.at.length;
    if (n < 2) return;
    var p = parts(plot), cross = p[0], tip = p[1];
    var box = plot.getBoundingClientRect();
    var f = Math.min(1, Math.max(0, (clientX - box.left) / box.width));
    var i = Math.round(f * (n - 1));
    var x = (i / (n - 1)) * box.width;
    cross.style.left = x + "px";
    cross.hidden = false;
    tip.textContent = "";
    var h = document.createElement("b");
    h.textContent = data.at[i];
    tip.appendChild(h);
    data.rows.forEach(function (r) {
      var line = document.createElement("div");
      var k = document.createElement("span");
      k.textContent = r.name;
      var v = document.createElement("span");
      v.textContent = r.v[i];
      line.appendChild(k);
      line.appendChild(v);
      tip.appendChild(line);
    });
    tip.hidden = false;
    var left = x + 12;
    if (left + tip.offsetWidth > box.width) left = x - 12 - tip.offsetWidth;
    tip.style.left = Math.max(0, left) + "px";
  }
  document.addEventListener("pointermove", function (e) {
    var plot = e.target.closest && e.target.closest("[data-hover]");
    if (plot !== hovered) { hide(hovered); hovered = plot; }
    if (plot) show(plot, e.clientX);
  });
  document.addEventListener("pointerleave", function () { hide(hovered); hovered = null; });

  // The globe.
  if (!window.d3 || !window.topojson) return;
  var world = null, globe = null, seen = null;
  function ensure() {
    var el = document.getElementById("tr-globe");
    var data = document.getElementById("tr-globe-data");
    if (!el || !data || !world) return;
    var raw = data.getAttribute("data-countries") || "{}";
    // Called on every change to the page, hover tooltips included; only a
    // new globe or new numbers are worth the work.
    if (globe && globe.el === el && raw === seen) return;
    if (!globe || globe.el !== el) globe = draw(el, world);
    seen = raw;
    try { globe.recolor(JSON.parse(raw) || {}); } catch (e) { /* keep the colours it has */ }
  }
  var first = document.getElementById("tr-globe");
  if (first) {
    fetch(first.getAttribute("data-world")).then(function (r) { return r.json(); }).then(function (w) {
      world = w;
      ensure();
    }).catch(function () { /* the list beside it still says everything */ });
  }
  // A patch changes #tr-globe-data's numbers, or brings the globe in
  // when the first rows of a quiet window arrive.
  new MutationObserver(ensure).observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-countries"] });

  function draw(el, world) {
    var css = getComputedStyle(document.documentElement);
    var land = css.getPropertyValue("--rule").trim();
    var hi = css.getPropertyValue("--accent").trim();
    var features = topojson.feature(world, world.objects.countries).features;
    var ns = "http://www.w3.org/2000/svg";
    var svg = document.createElementNS(ns, "svg");
    svg.setAttribute("role", "img");
    svg.setAttribute("aria-label", el.getAttribute("aria-label") || "");
    el.appendChild(svg);
    var tip = document.createElement("div");
    tip.className = "tr-tip";
    tip.hidden = true;
    el.appendChild(tip);
    var sphere = document.createElementNS(ns, "path");
    sphere.setAttribute("class", "tr-sphere");
    var grat = document.createElementNS(ns, "path");
    grat.setAttribute("class", "tr-grat");
    svg.appendChild(sphere);
    svg.appendChild(grat);
    var graticule = d3.geoGraticule10();
    var now = {}, moved = false;
    var paths = features.map(function (f) {
      var p = document.createElementNS(ns, "path");
      var id = String(+f.id);
      p.setAttribute("class", "tr-land");
      p.addEventListener("pointermove", function (e) {
        var c = now[id];
        if (!c) { tip.hidden = true; return; }
        var box = el.getBoundingClientRect();
        tip.textContent = "";
        var b = document.createElement("b");
        b.textContent = c.name;
        var d = document.createElement("div");
        d.textContent = c.label;
        tip.appendChild(b);
        tip.appendChild(d);
        tip.hidden = false;
        tip.style.left = Math.min(e.clientX - box.left + 12, box.width - tip.offsetWidth) + "px";
        tip.style.top = (e.clientY - box.top + 12) + "px";
      });
      p.addEventListener("pointerleave", function () { tip.hidden = true; });
      p.addEventListener("click", function () { if (!moved && now[id]) location.href = now[id].href; });
      svg.appendChild(p);
      return [p, f, id];
    });
    var proj, path;
    function layout() {
      var size = el.clientWidth;
      svg.setAttribute("viewBox", "0 0 " + size + " " + size);
      var rot = proj ? proj.rotate() : [40, -30];
      proj = d3.geoOrthographic().scale(size / 2 - 2).translate([size / 2, size / 2]).rotate(rot).clipAngle(90);
      path = d3.geoPath(proj);
      render();
    }
    function render() {
      sphere.setAttribute("d", path({ type: "Sphere" }));
      grat.setAttribute("d", path(graticule) || "");
      paths.forEach(function (pf) { pf[0].setAttribute("d", path(pf[1]) || ""); });
    }
    function recolor(c) {
      now = c;
      var max = 1;
      Object.keys(c).forEach(function (k) { max = Math.max(max, c[k].n); });
      paths.forEach(function (pf) {
        var x = c[pf[2]];
        // A log scale, so one busy country does not leave the rest blank.
        var f = x ? Math.log(Math.max(1, x.n)) / Math.log(Math.max(2, max)) : 0;
        pf[0].style.fill = x ? "color-mix(in srgb, " + hi + " " + Math.round(20 + 80 * f) + "%, " + land + ")" : land;
        pf[0].style.cursor = x ? "pointer" : "";
      });
    }
    layout();
    var spin = !matchMedia("(prefers-reduced-motion: reduce)").matches;
    var dragging = false, last;
    function tick() {
      if (!el.isConnected) return;
      if (spin && !dragging && document.visibilityState === "visible") {
        var r = proj.rotate();
        proj.rotate([r[0] + 0.12, r[1]]);
        render();
      }
      requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);
    svg.addEventListener("pointerdown", function (e) {
      dragging = true; moved = false; spin = false; last = [e.clientX, e.clientY];
      svg.setPointerCapture(e.pointerId);
    });
    svg.addEventListener("pointermove", function (e) {
      if (!dragging) return;
      var k = 75 / proj.scale(), r = proj.rotate();
      var dx = e.clientX - last[0], dy = e.clientY - last[1];
      if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
      proj.rotate([r[0] + dx * k, Math.max(-80, Math.min(80, r[1] - dy * k))]);
      last = [e.clientX, e.clientY];
      render();
    });
    svg.addEventListener("pointerup", function () { dragging = false; });
    var w = el.clientWidth;
    addEventListener("resize", function () { if (el.clientWidth !== w) { w = el.clientWidth; layout(); } });
    return { el: el, recolor: recolor };
  }
})();