summaryrefslogtreecommitdiff
path: root/assets/js/map/choropleth.js
blob: 52627640cfe0bccf1f4a25c233a310f5617db5e1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm";

const WIDTH = 960;
const HEIGHT = 500;
const DEFAULT_FILL = "rgba(36, 171, 72, 0.8)";
const NO_DATA_FILL = "#444";

// Module-level state shared across exported functions
let svg, mapGroup, projection, pathGen, features;
let _countries, _isoLookup;
let _clickHandler = null;
let _labelsVisible = false;

export async function initMap(container, countries, isoLookup) {
	_countries = countries;
	_isoLookup = isoLookup;

	svg = d3.select(container)
		.append("svg")
		.attr("viewBox", `0 0 ${WIDTH} ${HEIGHT}`)
		.attr("preserveAspectRatio", "xMidYMid meet")
		.style("width", "100%")
		.style("height", "100%");

	mapGroup = svg.append("g").attr("id", "map-layer");

	const world = await d3.json("./assets/data/world-110m.json");
	const geojson = topojson.feature(world, world.objects.countries);
	features = geojson.features;

	projection = d3.geoNaturalEarth1().fitSize([WIDTH, HEIGHT], geojson);
	pathGen = d3.geoPath().projection(projection);

	mapGroup.selectAll("path")
		.data(features)
		.join("path")
		.attr("class", "country")
		.attr("d", pathGen)
		.attr("fill", d => _fillForFeature(d, null, null))
		.attr("stroke", "#111")
		.attr("stroke-width", 0.5)
		.on("click", (event, d) => {
			if (_clickHandler) _clickHandler(event, d);
		});

	// Country name labels, hidden by default
	mapGroup.selectAll("text.country-label")
		.data(features)
		.join("text")
		.attr("class", "country-label")
		.attr("transform", d => {
			const [x, y] = pathGen.centroid(d);
			return isNaN(x) || isNaN(y) ? "translate(-9999,-9999)" : `translate(${x},${y})`;
		})
		.attr("text-anchor", "middle")
		.attr("dy", "0.35em")
		.style("font-size", "7px")
		.style("fill", "#fff")
		.style("pointer-events", "none")
		.style("display", "none")
		.text(d => {
			const alpha3 = _isoLookup[+d.id];
			return alpha3 && _countries[alpha3] ? _countries[alpha3].country : "";
		});

	return { svg, mapGroup, projection };
}

export function getCountryPaths() {
	return mapGroup.selectAll("path.country");
}

export function applyGradient(metricKey) {
	const values = Object.values(_countries)
		.map(c => c[metricKey])
		.filter(v => typeof v === "number" && !isNaN(v));

	const [min, max] = d3.extent(values);
	const colorScale = d3.scaleSequential()
		.domain([min, max])
		.interpolator(d3.interpolateYlOrRd);

	mapGroup.selectAll("path.country")
		.transition()
		.duration(400)
		.attr("fill", d => _fillForFeature(d, metricKey, colorScale));
}

export function resetGradient() {
	mapGroup.selectAll("path.country")
		.transition()
		.duration(400)
		.attr("fill", d => _fillForFeature(d, null, null));
}

export function toggleLabels() {
	_labelsVisible = !_labelsVisible;
	mapGroup.selectAll("text.country-label")
		.style("display", _labelsVisible ? "block" : "none");
}

export function setClickHandler(fn) {
	_clickHandler = fn;
}

function _fillForFeature(d, metricKey, colorScale) {
	const alpha3 = _isoLookup[+d.id];
	if (!alpha3 || !_countries[alpha3]) return NO_DATA_FILL;
	if (!metricKey) return DEFAULT_FILL;
	const val = _countries[alpha3][metricKey];
	return typeof val === "number" && !isNaN(val) ? colorScale(val) : NO_DATA_FILL;
}