Choropleth maps in R with canvasXpress

R
canvasXpress
maps
choropleth
visualization
Build a rich, honest, interactive choropleth in R with canvasXpress — quantile classes and a viridis ramp that survive heavy-tailed data, plus the area-bias trap and sequential vs diverging palettes.
Author

Isaac Neuhaus

Published

October 6, 2026

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. This post builds a choropleth in R with canvasXpress that stays vivid and honest on exactly the kind of heavy-tailed data that flattens naive maps. The join and the colouring happen inside the render, so every figure is one portable spec you can re-run from R, Python or the browser.

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 countries 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: cases per 100k, emissions per capita, sales per store. Reserve raw counts for a proportional-symbol map (a sized bubble at each centroid), where the size channel carries the count honestly. Decide which question you are answering before you pick the geometry — this one choice prevents most misleading maps.

One value per region

A choropleth needs exactly one measurement per shape. In canvasXpress the columns are the regions (they match the map by name) and the row is the variable you colour by — here CO₂ emissions per country for 2018. Load a prepared table; swap it for your own (any country → value table works).

library(canvasXpress)

y <- read.table(
  "https://www.canvasxpress.org/data/r/cX-CO2-dat.txt",
  header = TRUE, sep = "\t", quote = "", row.names = 1,
  check.names = FALSE, stringsAsFactors = FALSE
)

dim(y)              # 1 row (CO2) x N countries
[1]   1 198
y[, 1:4]
    Afghanistan  Albania  Algeria Andorra
CO2    10.45267 5.402999 164.3093 0.46421

The single row is the variable (CO2); each column name is a country that must match a shape in the map. An optional sample annotation table (rows = countries) rides along for richer tooltips and legends — here the continent and ISO code.

x <- read.table(
  "https://www.canvasxpress.org/data/r/cX-CO2-smp.txt",
  header = TRUE, sep = "\t", quote = "", row.names = 1,
  check.names = FALSE, stringsAsFactors = FALSE
)
head(x)
            Code     Continent     Country
Afghanistan  AFG          Asia Afghanistan
Albania      ALB        Europe     Albania
Algeria      DZA        Africa     Algeria
Andorra      AND        Europe     Andorra
Angola       AGO        Africa      Angola
Anguilla     AIA North America    Anguilla

The map: quantile classes computed in R, painted by the map

graphType = "Map" with a topoJSON of world boundaries draws the shapes and joins the countries by name — no manual merge, no projection math. The statistics stay in R, where they belong: we cut the values into seven quantile classes with quantile() + cut(), so each class holds an equal number of countries (~28 of 198), then colour by that classed annotation with a viridis palette. On skewed data that equal-count guarantee is what keeps all seven colours on the map.

vals   <- as.numeric(y[1, ])
breaks <- unique(quantile(vals, probs = seq(0, 1, length.out = 8)))
cls    <- cut(vals, breaks = breaks, include.lowest = TRUE)
x$Emissions <- sprintf(
  "Q%d: %s", as.integer(cls),
  gsub(",", " - ", gsub("[][()]", "", levels(cls)))[as.integer(cls)]
)
table(x$Emissions)   # ~28 countries per class

Q1: 0.00765 - 0.564    Q2: 0.564 - 2.69      Q3: 2.69 - 7.8      Q4: 7.8 - 22.4 
                 29                  28                  28                  28 
      Q5: 22.4 - 46        Q6: 46 - 208  Q7: 208 - 9.96e+03 
                 28                  28                  29 
canvasXpress(
  data      = y,
  smpAnnot  = x,
  graphType = "Map",
  colorBy   = "Emissions",
  topoJSON  = "https://www.canvasxpress.org/data/maps/WORLD.json",
  mapId     = "co2Quantile",
  colors = list("#440154", "#443983", "#31688e", "#21918c",
                "#35b779", "#90d743", "#fde725", "#d9dde3"),
  mapColor        = "#d9dde3",
  mapOutlineColor = "#ffffff",
  legendPosition  = "bottom",
  legendColumns   = 4,
  title    = "CO2 emissions per country (2018)",
  subtitle = "Seven quantile classes computed in R: each colour holds about 28 of 198 countries",
  width  = 760,
  height = 560
)

Every band of the ramp is doing work: the darkest purples are the smallest emitters, the yellows the largest, and the legend states each class’s actual value range — the edges are data-driven, not evenly spaced. The eighth colour, a neutral grey, catches map regions with no data so missing reads as missing. Hover any country for its name, continent, class and value.

If you prefer to keep the classing inside the engine, current CanvasXpress builds accept colorSpectrum (the ramp) plus colorSpectrumBreaksMethod = "quantile" and do the same cut at render time — the R route above works everywhere and keeps the breaks inspectable in your session.

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:

canvasXpress(
  data      = y,
  smpAnnot  = x,
  graphType = "Map",
  colorBy   = "CO2",
  topoJSON  = "https://www.canvasxpress.org/data/maps/WORLD.json",
  mapId     = "co2Linear",
  legendPosition = "left",
  theme = "solarized",
  title    = "The same data on a plain linear ramp",
  subtitle = "195 of 198 countries sit in the bottom fifth of the scale - the map goes flat",
  width  = 700,
  height = 480
)

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 not decoration — it is the scale’s grammar:

  • 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. Give canvasXpress a three-colour colorSpectrum and pin the midpoint with colorSpectrumZeroValue:

    colorBy = "Change",
    colorSpectrum = list("#2166ac", "#f7f7f7", "#b2182b"),  # blue - neutral - red
    colorSpectrumZeroValue = 0                               # value mapped to the middle colour

    Never use a diverging palette for a purely positive quantity — the neutral midpoint invents a “normal” that the data does not have.

Viridis and the ColorBrewer ramps are colour-blind-safe and perceptually ordered; a rainbow ramp is neither, so readers cannot tell which end means “more”. Keep the same scale across small multiples so maps stay comparable.

Polish: legend, labels and base styling

  • subtitle / source — caption the units and provenance.
  • legendNumberFormat = list(style = "decimal", decimals = 1, suffix = "%") — format the legend ticks as percentages, currency, etc.
  • mapColor / mapOutlineColor — the fill for regions with no data (a neutral grey) and the border between shapes, so missing data reads as missing rather than as a value.
  • showMapLabels = TRUE — draw region names (best on maps with few regions, e.g. a single country’s provinces; it clutters a 190-country world map).
  • legendInside = TRUE — float the legend over empty ocean to reclaim width.

Beyond the world map

  • Other boundaries. Point topoJSON at any shape file — US states, a single country’s regions, counties, custom districts. The join key is just the shape name.
  • Projection. mapProjection = "albers" (equal-area, good for the US) or "mercator". Equal-area projections matter for choropleths: Mercator inflates high latitudes, exaggerating those regions’ visual weight.
  • Categorical maps. Point colorBy at a categorical column (an election winner, a priority tier) for a qualitative choropleth with a discrete legend.
  • Markers. Add decorations to drop labelled points on cities or call out specific regions.

The whole figure — data, boundaries, classing rule and palette — travels together as one spec, so a reviewer can reproduce it exactly and a colleague can re-render it from Python or JavaScript.

Next