krz/world-incarceration

An interactive map of world incarceration statistics. data-viz incarceration interactive maps

Commit fd412c5197

fd412c5197286518ff9491a1b51e6319c9a22904

parent: 9b6de370a0

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-17 17:40 UTC

phase 2 implement

Layout: unified · split

assets/css/app.css +16 −2
@@ -104,6 +104,20 @@ th {
104 font-size: 16px; 104 font-size: 16px;
105} 105}
106 106
107.zc-ref { 107#map-container {
108 display: none; 108 height: calc(100vh - 56px);
109 width: 100%;
110 background: #161616;
111}
112
113.map-tooltip {
114 position: absolute;
115 background: #1a1a1a;
116 color: #fff;
117 padding: 8px 12px;
118 border: 1px solid #24ab48;
119 border-radius: 0;
120 pointer-events: none;
121 font-size: 13px;
122 opacity: 0;
109} 123}
assets/js/app.js +2 −2
@@ -2,7 +2,7 @@ import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
2import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm"; 2import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm";
3import { countries } from "../data/countries.js"; 3import { countries } from "../data/countries.js";
4import { isoNumericToAlpha3 } from "../data/iso-numeric-to-alpha3.js"; 4import { isoNumericToAlpha3 } from "../data/iso-numeric-to-alpha3.js";
5import { initMap, applyGradient, setClickHandler } from "./map/choropleth.js"; 5import { initMap, applyGradient, resetGradient, setClickHandler } from "./map/choropleth.js";
6import { initTooltip } from "./map/tooltip.js"; 6import { initTooltip } from "./map/tooltip.js";
7import { initZoom, resetZoom } from "./map/zoom.js"; 7import { initZoom, resetZoom } from "./map/zoom.js";
8import { renderBarChart } from "./charts/bar.js"; 8import { renderBarChart } from "./charts/bar.js";
@@ -11,4 +11,4 @@ import { showCountryModal } from "./ui/modal.js";
11import { initCompare } from "./ui/compare.js"; 11import { initCompare } from "./ui/compare.js";
12import { initControls, getTop5 } from "./ui/controls.js"; 12import { initControls, getTop5 } from "./ui/controls.js";
13 13
14// Phase 2+: wire modules together here 14initMap("#map-container", countries, isoNumericToAlpha3);
assets/js/map/choropleth.js +82 −4
@@ -1,4 +1,82 @@
1// Phase 2: choropleth map rendering, gradient coloring 1import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
2export function initMap(container, countries, isoLookup) {} 2import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm";
3export function applyGradient(metricKey) {} 3
4export function setClickHandler(fn) {} 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;
13
14export async function initMap(container, countries, isoLookup) {
15 _countries = countries;
16 _isoLookup = isoLookup;
17
18 svg = d3.select(container)
19 .append("svg")
20 .attr("viewBox", `0 0 ${WIDTH} ${HEIGHT}`)
21 .attr("preserveAspectRatio", "xMidYMid meet")
22 .style("width", "100%")
23 .style("height", "100%");
24
25 mapGroup = svg.append("g").attr("id", "map-layer");
26
27 const world = await d3.json("./assets/data/world-110m.json");
28 const geojson = topojson.feature(world, world.objects.countries);
29 features = geojson.features;
30
31 projection = d3.geoNaturalEarth1().fitSize([WIDTH, HEIGHT], geojson);
32 pathGen = d3.geoPath().projection(projection);
33
34 mapGroup.selectAll("path")
35 .data(features)
36 .join("path")
37 .attr("class", "country")
38 .attr("d", pathGen)
39 .attr("fill", d => _fillForFeature(d, null, null))
40 .attr("stroke", "#111")
41 .attr("stroke-width", 0.5)
42 .on("click", (event, d) => {
43 if (_clickHandler) _clickHandler(event, d);
44 });
45
46 return { svg, mapGroup, projection };
47}
48
49export function applyGradient(metricKey) {
50 const values = Object.values(_countries)
51 .map(c => c[metricKey])
52 .filter(v => typeof v === "number" && !isNaN(v));
53
54 const [min, max] = d3.extent(values);
55 const colorScale = d3.scaleSequential()
56 .domain([min, max])
57 .interpolator(d3.interpolateYlOrRd);
58
59 mapGroup.selectAll("path.country")
60 .transition()
61 .duration(400)
62 .attr("fill", d => _fillForFeature(d, metricKey, colorScale));
63}
64
65export function resetGradient() {
66 mapGroup.selectAll("path.country")
67 .transition()
68 .duration(400)
69 .attr("fill", d => _fillForFeature(d, null, null));
70}
71
72export function setClickHandler(fn) {
73 _clickHandler = fn;
74}
75
76function _fillForFeature(d, metricKey, colorScale) {
77 const alpha3 = _isoLookup[+d.id];
78 if (!alpha3 || !_countries[alpha3]) return NO_DATA_FILL;
79 if (!metricKey) return DEFAULT_FILL;
80 const val = _countries[alpha3][metricKey];
81 return typeof val === "number" && !isNaN(val) ? colorScale(val) : NO_DATA_FILL;
82}
index.html +1 −1
@@ -449,7 +449,7 @@
449 </div> 449 </div>
450 450
451 <!-- map area --> 451 <!-- map area -->
452 <div id="myChart"></div> 452 <div id="map-container"></div>
453 453
454 <!-- js --> 454 <!-- js -->
455 <script src="./assets/js/bootstrap.bundle.min.js"></script> 455 <script src="./assets/js/bootstrap.bundle.min.js"></script>