Choropleth maps in JavaScript with CanvasXpress
A choropleth shades each region by a value — emissions, incidence, turnout, income. It is the most common map in data analysis and the easiest to get subtly wrong: most “my map is all one colour” complaints are not rendering bugs but scale bugs. CanvasXpress does the join and the colouring inside the render, so in the browser you pass one value per region and a topoJSON of boundaries and get an interactive, reproducible figure — and with the right colour scale it stays vivid on exactly the kind of heavy-tailed data that flattens naive maps.
First, the trap: colour encodes a rate, not a count
A choropleth fills an area, and area is a size channel your eye cannot help reading. Shade regions by a raw count — total cases, total emissions, total sales — and big, populous regions light up simply because they are big and populous. You have drawn a population map with extra steps. The fix is almost always to map a rate or density (per capita, per 100k); reserve raw counts for a proportional-symbol map, where a sized bubble carries the count honestly.
Load the library
<link href="https://cdn.jsdelivr.net/gh/neuhausi/canvasXpress@71.3.0/inst/htmlwidgets/lib/canvasXpress/canvasXpress.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/gh/neuhausi/canvasXpress@71.3.0/inst/htmlwidgets/lib/canvasXpress/canvasXpress.min.js"></script>The markup and the spec
The columns (smps) are the regions — they match the map by name — and the single row (vars) is the variable you colour by: CO₂ emissions per country for 2018. We fetch a prepared table and a small annotation table (continent, ISO code), parse the tab-separated text, and hand the matrix to CanvasXpress. The two choices that make this map work are in the colour scale: a seven-stop viridis colorSpectrum, and colorSpectrumBreaksMethod: "quantile", which puts an equal number of countries in every colour class — on skewed data that guarantee is what keeps all seven colours on the map.
<canvas id="co2quant" width="760" height="520"></canvas>
<script>
var DATA = "https://www.canvasxpress.org/data/r/cX-CO2-dat.txt";
var SMP = "https://www.canvasxpress.org/data/r/cX-CO2-smp.txt";
// split a tab-separated file into a list of cell-arrays (one per row)
function rows(text) {
return text.trim().split("\n").map(function (line) { return line.split("\t"); });
}
Promise.all([fetch(DATA).then(function (r) { return r.text(); }),
fetch(SMP ).then(function (r) { return r.text(); })])
.then(function (texts) {
var d = rows(texts[0]); // [header, ["CO2", v1, v2, ...]]
var countries = d[0].slice(1); // header, minus the empty corner cell
var values = d[1].slice(1).map(Number); // the CO2 values
var s = rows(texts[1]); // header + one row per country
var col = {}; // header name -> column index
s[0].forEach(function (name, i) { col[name] = i; });
var byCountry = {};
s.slice(1).forEach(function (r) { byCountry[r[0]] = r; });
var continent = countries.map(function (c) { return byCountry[c][col["Continent"] + 1]; });
var data = {
y: { vars: ["CO2"], smps: countries, data: [values] },
x: { Continent: continent }
};
var config = {
graphType: "Map",
colorBy: "CO2",
topoJSON: "https://www.canvasxpress.org/data/maps/WORLD.json",
mapId: "co2Quantile",
colorSpectrum: ["#440154", "#443983", "#31688e", "#21918c",
"#35b779", "#90d743", "#fde725"],
colorSpectrumBreaksMethod: "quantile",
mapColor: "#d9dde3",
mapOutlineColor: "#ffffff",
legendPosition: "bottom",
title: "CO2 emissions per country (2018)",
subtitle: "Seven quantile classes: each colour holds about 28 of 198 countries"
};
new CanvasXpress("co2quant", data, config);
});
</script>Live result
Every band of the ramp is doing work: the darkest purples are the smallest emitters, the yellows the largest, and the legend’s break values tell you the class edges are data-driven, not evenly spaced. Hover any country for its name, continent and value.
Why not just a plain linear ramp?
Because the data is heavy-tailed — a few countries emit thousands of megatonnes while most emit almost nothing. On a linear scale, 195 of these 198 countries fall into the bottom fifth of the range, so a continuous ramp paints nearly the whole world one pale tone and spends its colour on two or three outliers. See for yourself — same data, default scale:
This is the single most common choropleth failure. The cure is classing:
colorSpectrumBreaksMethod: "quantile"— equal counts per class. Every colour is used, by construction. The right default for skewed data and for “which regions rank high/low” questions.colorSpectrumBreaksMethod: "jenks"— natural breaks that minimise within-class variance. Best when the data has genuine clusters with gaps between them; on smoothly heavy-tailed data like this it piles almost everything into the bottom class, so check the legend before you trust it.- Three to seven classes is the usable range — beyond that, readers cannot match colours to the legend.
Choose the palette on purpose
The palette is the scale’s grammar, not decoration:
Sequential (dark → light of ordered hues, like viridis above) for a quantity that only goes up: counts, rates, concentrations.
Diverging (two hues meeting at a neutral midpoint) when values are meaningful above and below a reference: change vs. last year, difference from a target, a signed index. Use a three-colour
colorSpectrumand pin the midpoint:colorBy: "Change", colorSpectrum: ["#2166ac", "#f7f7f7", "#b2182b"], // blue - neutral - red colorSpectrumZeroValue: 0 // value mapped to the middle colourNever use a diverging palette for a purely positive quantity — the neutral midpoint invents a “normal” the data does not have. Viridis and the ColorBrewer ramps are colour-blind-safe and perceptually ordered; a rainbow ramp is neither. Keep the same scale across small multiples so maps stay comparable.
Beyond the world map
- Other boundaries. Point
topoJSONat any shape file — US states, a country’s provinces, counties, custom districts. The join key is just the shape name. - Projection.
mapProjection: "albers"(equal-area) or"mercator". Equal-area projections matter for choropleths: Mercator inflates high latitudes and exaggerates their visual weight. - Categorical maps. Point
colorByat a categorical field for a qualitative choropleth with a discrete legend. - Polish.
legendNumberFormatformats the ticks,legendInsidefloats the legend over ocean,showMapLabelsdraws region names (best on few-region maps),mapColorsets the no-data fill so missing reads as missing, anddecorationsdrops labelled markers on cities.
The whole figure — data, boundaries, classing rule and palette — is one portable spec you can re-render from R or Python.