Choropleth maps in JavaScript with CanvasXpress

JavaScript
CanvasXpress
maps
choropleth
visualization
Build a rich, honest, interactive choropleth in the browser 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 8, 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. 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 colorSpectrum and pin the midpoint:

    colorBy: "Change",
    colorSpectrum: ["#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” 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 topoJSON at 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 colorBy at a categorical field for a qualitative choropleth with a discrete legend.
  • Polish. legendNumberFormat formats the ticks, legendInside floats the legend over ocean, showMapLabels draws region names (best on few-region maps), mapColor sets the no-data fill so missing reads as missing, and decorations drops 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.

Next