krz/world-incarceration

An interactive map of world incarceration statistics.

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

fd412c5197286518ff9491a1b51e6319c9a22904

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-17T17:40:55Z

phase 2 implement
 assets/css/app.css          | 18 ++++++++--
 assets/js/app.js            |  4 +--
 assets/js/map/choropleth.js | 86 ++++++++++++++++++++++++++++++++++++++++++---
 index.html                  |  2 +-
 4 files changed, 101 insertions(+), 9 deletions(-)

diff --git a/assets/css/app.css b/assets/css/app.css
index 65e4ecf..95310fb 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -104,6 +104,20 @@ th {
 	font-size: 16px;
 }
 
-.zc-ref {
-	display: none;
+#map-container {
+	height: calc(100vh - 56px);
+	width: 100%;
+	background: #161616;
+}
+
+.map-tooltip {
+	position: absolute;
+	background: #1a1a1a;
+	color: #fff;
+	padding: 8px 12px;
+	border: 1px solid #24ab48;
+	border-radius: 0;
+	pointer-events: none;
+	font-size: 13px;
+	opacity: 0;
 }
diff --git a/assets/js/app.js b/assets/js/app.js
index 51ff0b4..aca4ac9 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -2,7 +2,7 @@ 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";
 import { countries } from "../data/countries.js";
 import { isoNumericToAlpha3 } from "../data/iso-numeric-to-alpha3.js";
-import { initMap, applyGradient, setClickHandler } from "./map/choropleth.js";
+import { initMap, applyGradient, resetGradient, setClickHandler } from "./map/choropleth.js";
 import { initTooltip } from "./map/tooltip.js";
 import { initZoom, resetZoom } from "./map/zoom.js";
 import { renderBarChart } from "./charts/bar.js";
@@ -11,4 +11,4 @@ import { showCountryModal } from "./ui/modal.js";
 import { initCompare } from "./ui/compare.js";
 import { initControls, getTop5 } from "./ui/controls.js";
 
-// Phase 2+: wire modules together here
+initMap("#map-container", countries, isoNumericToAlpha3);
diff --git a/assets/js/map/choropleth.js b/assets/js/map/choropleth.js
index e7d5d3d..9316f29 100644
--- a/assets/js/map/choropleth.js
+++ b/assets/js/map/choropleth.js
@@ -1,4 +1,82 @@
-// Phase 2: choropleth map rendering, gradient coloring
-export function initMap(container, countries, isoLookup) {}
-export function applyGradient(metricKey) {}
-export function setClickHandler(fn) {}
+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;
+
+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);
+		});
+
+	return { svg, mapGroup, projection };
+}
+
+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 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;
+}
diff --git a/index.html b/index.html
index 0cb3127..0915286 100644
--- a/index.html
+++ b/index.html
@@ -449,7 +449,7 @@
 		</div>
 
 		<!-- map area -->
-		<div id="myChart"></div>
+		<div id="map-container"></div>
 
 		<!-- js -->
 		<script src="./assets/js/bootstrap.bundle.min.js"></script>