krz/world-incarceration

An interactive map of world incarceration statistics.

clone: git clone https://gitbay.org/krz/world-incarceration.git

v2: assets/js/map/choropleth.js · raw

  1import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
  2import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm";
  3
  4const WIDTH = 960;
  5const HEIGHT = 500;
  6const DEFAULT_FILL = "rgba(36, 171, 72, 0.8)";
  7const NO_DATA_FILL = "#444";
  8
  9// Module-level state shared across exported functions
 10let svg, mapGroup, projection, pathGen, features;
 11let _countries, _isoLookup;
 12let _clickHandler = null;
 13let _labelsVisible = false;
 14
 15export async function initMap(container, countries, isoLookup) {
 16	_countries = countries;
 17	_isoLookup = isoLookup;
 18
 19	svg = d3.select(container)
 20		.append("svg")
 21		.attr("viewBox", `0 0 ${WIDTH} ${HEIGHT}`)
 22		.attr("preserveAspectRatio", "xMidYMid meet")
 23		.style("width", "100%")
 24		.style("height", "100%");
 25
 26	mapGroup = svg.append("g").attr("id", "map-layer");
 27
 28	const world = await d3.json("./assets/data/world-110m.json");
 29	const geojson = topojson.feature(world, world.objects.countries);
 30	features = geojson.features;
 31
 32	projection = d3.geoNaturalEarth1().fitSize([WIDTH, HEIGHT], geojson);
 33	pathGen = d3.geoPath().projection(projection);
 34
 35	mapGroup.selectAll("path")
 36		.data(features)
 37		.join("path")
 38		.attr("class", "country")
 39		.attr("d", pathGen)
 40		.attr("fill", d => _fillForFeature(d, null, null))
 41		.attr("stroke", "#111")
 42		.attr("stroke-width", 0.5)
 43		.on("click", (event, d) => {
 44			if (_clickHandler) _clickHandler(event, d);
 45		});
 46
 47	// Country name labels, hidden by default
 48	mapGroup.selectAll("text.country-label")
 49		.data(features)
 50		.join("text")
 51		.attr("class", "country-label")
 52		.attr("transform", d => {
 53			const [x, y] = pathGen.centroid(d);
 54			return isNaN(x) || isNaN(y) ? "translate(-9999,-9999)" : `translate(${x},${y})`;
 55		})
 56		.attr("text-anchor", "middle")
 57		.attr("dy", "0.35em")
 58		.style("font-size", "7px")
 59		.style("fill", "#fff")
 60		.style("pointer-events", "none")
 61		.style("display", "none")
 62		.text(d => {
 63			const alpha3 = _isoLookup[+d.id];
 64			return alpha3 && _countries[alpha3] ? _countries[alpha3].country : "";
 65		});
 66
 67	return { svg, mapGroup, projection };
 68}
 69
 70export function getCountryPaths() {
 71	return mapGroup.selectAll("path.country");
 72}
 73
 74export function applyGradient(metricKey) {
 75	const values = Object.values(_countries)
 76		.map(c => c[metricKey])
 77		.filter(v => typeof v === "number" && !isNaN(v));
 78
 79	const [min, max] = d3.extent(values);
 80	const colorScale = d3.scaleSequential()
 81		.domain([min, max])
 82		.interpolator(d3.interpolateYlOrRd);
 83
 84	mapGroup.selectAll("path.country")
 85		.transition()
 86		.duration(400)
 87		.attr("fill", d => _fillForFeature(d, metricKey, colorScale));
 88}
 89
 90export function resetGradient() {
 91	mapGroup.selectAll("path.country")
 92		.transition()
 93		.duration(400)
 94		.attr("fill", d => _fillForFeature(d, null, null));
 95}
 96
 97export function toggleLabels() {
 98	_labelsVisible = !_labelsVisible;
 99	mapGroup.selectAll("text.country-label")
100		.style("display", _labelsVisible ? "block" : "none");
101}
102
103export function setClickHandler(fn) {
104	_clickHandler = fn;
105}
106
107function _fillForFeature(d, metricKey, colorScale) {
108	const alpha3 = _isoLookup[+d.id];
109	if (!alpha3 || !_countries[alpha3]) return NO_DATA_FILL;
110	if (!metricKey) return DEFAULT_FILL;
111	const val = _countries[alpha3][metricKey];
112	return typeof val === "number" && !isNaN(val) ? colorScale(val) : NO_DATA_FILL;
113}