Two variables, one map. Instead of a colour ramp, a bivariate choropleth splits each variable into three bands and gives every combination its own colour — so the map shows not just where countries are urban or rich, but where those two things come apart. The poor-and-urban corner is the interesting one.
fetching two World Bank indicators…
// which World Bank codes are countries rather than aggregates https://api.worldbank.org/v2/country?format=json&per_page=400 // the two variables https://api.worldbank.org/v2/country/all/indicator/SP.URB.TOTL.IN.ZS?format=json&per_page=400&date=2023 https://api.worldbank.org/v2/country/all/indicator/NY.GDP.PCAP.CD?format=json&per_page=400&date=2023 // geometry — mapjson https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium&properties=iso2,name
The key in the corner is the legend and the argument at once: urbanisation increases left to right, income bottom to top. Colour tells you the pair. The diagonal — poor and rural at one end, rich and urban at the other — holds most countries, so the off-diagonal corners are where the map earns its keep: the Gulf states and much of Latin America sit urban well ahead of income, while a handful of rich countries stay comparatively rural.
Bands are cut at the terciles of this data rather than at round numbers, so all nine cells are populated. Fixed thresholds tend to leave whole rows empty and waste a third of the palette. The trade-off is that the classes move as the data does; they describe the spread in a given year rather than an absolute standard.
The palette matters more than usual here. A bivariate scheme has to stay legible in all nine combinations at once, which rules out most sequential ramps — this is Joshua Stevens' widely used blue/purple matrix.
Small states get the same treatment as elsewhere in the gallery: under about six square pixels a country's cell colour cannot be read, so it also gets a dot in that colour, and the dot gives way to the real shape as you zoom in. The key stays outside the zoomed group and holds its corner.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Urbanisation and income, 2023</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
body { margin: 0; background: #efece6; font: 13px 'IBM Plex Mono', ui-monospace, monospace; color: #3b3a35; }
#map-wrap { position: relative; }
#map-wrap svg { width: 100%; display: block; }
.tip { position: absolute; background: #26251f; color: #f4f1ea; font-size: 11px; padding: 5px 9px;
pointer-events: none; opacity: 0; white-space: nowrap; line-height: 1.6; }
.controls { display: flex; align-items: center; gap: 14px; padding: 12px 14px; }
.controls button { font: inherit; padding: 5px 16px; border: 1px solid #3b3a35; background: #fff; cursor: pointer; }
.controls span { color: #6b6a63; }
</style>
</head>
<body>
<div id="map-wrap"><div class="tip" id="tip"></div></div>
<div class="controls">
<button id="reset">reset</button>
<span>scroll to zoom, drag to pan</span>
</div>
<script>
const YEAR = 2023;
const URBAN = "SP.URB.TOTL.IN.ZS", INCOME = "NY.GDP.PCAP.CD";
const wbSeries = (ind) => "https://api.worldbank.org/v2/country/all/indicator/" + ind
+ "?format=json&per_page=400&date=" + YEAR;
const WB_COUNTRIES = "https://api.worldbank.org/v2/country?format=json&per_page=400";
const GEO = "https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium"
+ "&properties=iso2,name&format=geojson";
// A bivariate map needs a 3x3 palette rather than a ramp: one axis per
// variable, and the corner colours have to stay apart. This is Joshua Stevens'
// blue/purple scheme — rows are income, columns are urbanisation.
const PALETTE = [
["#e8e8e8", "#ace4e4", "#5ac8c8"],
["#dfb0d6", "#a5add3", "#5698b9"],
["#be64ac", "#8c62aa", "#3b4994"],
];
const NO_DATA = "#d9d5cc";
// Below this projected size a country is about a pixel; its cell colour cannot
// be read, so it gets a dot in the same colour until zoom makes it legible.
const MIN_AREA = 6;
// World Bank responses mix in regional and income aggregates, and eleven of
// their aggregate codes collide with mapjson gids for tiny territories
// ("XD" = High income to them, Dhekelia to us). region.id "NA" marks one.
async function realCountryCodes() {
const [, rows] = await d3.json(WB_COUNTRIES);
return new Set(rows.filter(r => r.region.id !== "NA").map(r => r.iso2Code));
}
const series = (json, codes) => new Map(
json[1].filter(r => r.value != null && codes.has(r.country.id))
.map(r => [r.country.id, r.value])
);
(async function () {
const codes = await realCountryCodes();
const [urbJson, incJson, world] = await Promise.all([
d3.json(wbSeries(URBAN)),
d3.json(wbSeries(INCOME)),
d3.json(GEO),
]);
const urban = series(urbJson, codes), income = series(incJson, codes);
const joined = world.features.filter(f =>
urban.has(f.properties.iso2) && income.has(f.properties.iso2));
// Cut each variable at its own terciles, so the classes describe this data
// rather than a fixed threshold that would leave whole rows empty.
const cut = (get) => {
const v = joined.map(get).sort(d3.ascending);
return [d3.quantile(v, 1 / 3), d3.quantile(v, 2 / 3)];
};
const uCut = cut(f => urban.get(f.properties.iso2));
const iCut = cut(f => income.get(f.properties.iso2));
const band = (v, cuts) => (v < cuts[0] ? 0 : v < cuts[1] ? 1 : 2);
const cellOf = (f) => [
band(income.get(f.properties.iso2), iCut),
band(urban.get(f.properties.iso2), uCut),
];
const W = 1200, H = 620;
const svg = d3.select("#map-wrap").append("svg").attr("viewBox", "0 0 " + W + " " + H);
const projection = d3.geoEqualEarth().rotate([-11, 0]);
const path = d3.geoPath(projection);
projection.fitExtent([[10, 10], [W - 10, H - 24]], world);
// Tooltip coordinates are measured against #map-wrap explicitly. On SVG
// children Chrome reports the pointer offset relative to the hovered shape's
// own box, which lands the tooltip somewhere different for every country.
const wrap = document.getElementById("map-wrap");
const tip = d3.select("#tip");
const has = new Set(joined.map(f => f.properties.iso2));
const fmtMoney = d3.format("$,.0f");
const fillOf = (d) => {
if (!has.has(d.properties.iso2)) return NO_DATA;
const [i, u] = cellOf(d);
return PALETTE[i][u];
};
const hover = (e, d) => {
const iso = d.properties.iso2;
const [mx, my] = d3.pointer(e, wrap);
tip.style("opacity", 1)
.style("left", (mx + 14) + "px")
.style("top", (my - 6) + "px")
.html(has.has(iso)
? "<b>" + d.properties.name + "</b><br>"
+ urban.get(iso).toFixed(0) + "% urban<br>"
+ fmtMoney(income.get(iso)) + " per capita"
: d.properties.name + " · no data");
};
const away = () => tip.style("opacity", 0);
// The key stays out of this group so it keeps its size and corner.
const gZoom = svg.append("g");
const shapes = gZoom.append("g").selectAll("path").data(world.features).join("path")
.attr("d", path)
.attr("fill", fillOf)
.attr("stroke", "#efece6").attr("stroke-width", 0.4)
.on("mousemove", hover).on("mouseout", away);
const tiny = world.features.filter(d =>
has.has(d.properties.iso2) && path.area(d) < MIN_AREA);
const tinyArea = new Map(tiny.map(d => [d, path.area(d)]));
const dots = gZoom.append("g").selectAll("circle").data(tiny).join("circle")
.attr("cx", d => path.centroid(d)[0])
.attr("cy", d => path.centroid(d)[1])
.attr("r", 2.8)
.attr("fill", fillOf)
.attr("stroke", "#efece6").attr("stroke-width", 0.7)
.on("mousemove", hover).on("mouseout", away);
const zoom = d3.zoom().scaleExtent([1, 60]).on("zoom", (e) => {
const k = e.transform.k;
gZoom.attr("transform", e.transform);
shapes.attr("stroke-width", 0.4 / k);
dots.attr("r", 2.8 / k).attr("stroke-width", 0.7 / k)
.attr("display", d => (tinyArea.get(d) * k * k >= MIN_AREA ? "none" : null));
});
svg.call(zoom).style("cursor", "grab");
const resetBtn = document.getElementById("reset");
if (resetBtn) resetBtn.onclick = () =>
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
// ── 3x3 key, drawn as the map's own axes
const S = 26, kx = 34, ky = H - 132;
const key = svg.append("g").attr("transform", "translate(" + kx + "," + ky + ")");
for (let i = 0; i < 3; i++) {
for (let u = 0; u < 3; u++) {
key.append("rect")
.attr("x", u * S).attr("y", (2 - i) * S)
.attr("width", S).attr("height", S)
.attr("fill", PALETTE[i][u]);
}
}
const label = (x, y, text, rotate) => key.append("text")
.attr("x", x).attr("y", y).attr("font-size", 11).attr("fill", "#5a5952")
.attr("font-family", "'IBM Plex Mono', ui-monospace, monospace")
.attr("transform", rotate ? "rotate(-90," + x + "," + y + ")" : null)
.text(text);
label(0, 3 * S + 16, "more urban →");
label(-8, 3 * S, "richer →", true);
const live = document.getElementById("live");
if (live) live.innerHTML = "<b>" + joined.length + "</b> countries classed against their own "
+ "terciles — " + Math.round(uCut[0]) + "% / " + Math.round(uCut[1]) + "% urban, "
+ fmtMoney(iCut[0]) + " / " + fmtMoney(iCut[1]) + " per capita.";
})();
</script>
</body>
</html>