krz/world-incarceration
An interactive map of world incarceration statistics.
clone: git clone https://gitbay.org/krz/world-incarceration.git
main: 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}