// 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 };
}
})();