Choropleth Maps

A choropleth colors regions by value. ApexMaps needs exactly two things to draw one: which geometry to draw, and which data joins to it.

import ApexMaps from 'apexmaps'

const map = new ApexMaps(document.querySelector('#map'), {
  geo: { map: 'world/countries@110m' },
  series: [
    {
      name: 'Unemployment rate',
      joinBy: ['iso_a3', 'code'],
      data: [
        { code: 'FRA', value: 7.3 },
        { code: 'DEU', value: 5.7 },
      ],
    },
  ],
})

await map.render()

That's the whole configuration: no geometry to source, host or parse, and no projection, palette, classification, legend or tooltip to set up. Everything below is a default chosen to be publishable on its own, not just functional, so you only need to touch it when your data actually calls for something different.

What the defaults decide

DecisionDefaultWhy
ProjectionequalEarthWeb Mercator inflates high-latitude area by an order of magnitude, which is exactly wrong on a map where area sits next to a value-colored region
Classificationquantile, 5 classesEvery class ends up populated, and the legend shows the actual break values, because classification silently changes a choropleth's conclusion
RampOkLab-sampledInterpolating through sRGB turns ramp midpoints muddy grey; sampling in OkLab keeps perceived lightness monotonic, which is how a reader infers magnitude
No-data colorits own swatchAn unmatched feature never falls into the lowest class, which would understate it
Strokethin white lineSeparates adjacent same-class regions without competing with the fill

Override any of them through scale:

series: [
  {
    name: 'Unemployment rate',
    joinBy: ['iso_a3', 'code'],
    data,
    scale: {
      type: 'quantile',   // quantile, quantize, equalInterval, jenks, naturalBreaks, threshold, linear, log, sqrt, ordinal
      classes: 7,
      palette: 'blues',   // a registered palette name, or a literal array of colors
      nullColor: '#eeeeee',
      nullLabel: 'No data',
    },
  },
]

type: 'threshold' takes an explicit breaks array instead of a computed classes count, and domain fixes the input range yourself rather than letting it come from the data.

Joining data to geometry

joinBy tells ApexMaps which geometry property to match against which field on your rows. It accepts three equivalent shapes:

joinBy: 'name'                          // same property name on both sides
joinBy: ['iso_a3', 'code']              // [geometryProperty, dataKey]
joinBy: { geo: 'iso_a3', data: 'code' } // the same thing, spelled out

Matching stays exact by default, because silently guessing turns an obvious join failure into a plausible wrong answer. A data row that matches nothing, or a feature nothing matches, shows up in the dev-mode join diagnostics rather than rendering as silent grey, down to a "did you mean" suggestion for near-misses like "Ivory Coast" against a pack that spells it "Côte d'Ivoire". Set fuzzyJoin: true on the series to apply those suggestions automatically instead of fixing the data by hand.

Counts versus rates: normalizeBy

A choropleth of raw counts mostly redraws the map of where people already live: whichever country or county has the largest population wins nearly every category, and the map ends up saying nothing else. normalizeBy divides value by another field on the same row before classifying, which is what turns a population map back into an actual finding:

series: [
  {
    name: 'Cases',
    joinBy: ['iso_a3', 'code'],
    data,                        // [{ code, value, population }, ...]
    normalizeBy: 'population',   // legend retitles to "Cases (per population)"
  },
]

The legend retitles itself whenever normalizeBy is set, so a rate map and a count map can never be mistaken for one another at a glance. If your values are large integers with no normalizeBy at all, dev-mode diagnostics note it, not as an error since a count map is sometimes exactly the right chart, but because that shape of data is a raw count nine times out of ten. When magnitude itself is what you want to be legible, rather than a rate, a bubble series is usually the better fit: a circle's area doesn't inherit the size of the polygon underneath it.

Stroke styling

Feature borders are styled with stroke on the series:

series: [
  {
    name: 'Unemployment rate',
    joinBy: ['iso_a3', 'code'],
    data,
    stroke: {
      color: '#ffffff',
      width: 0.5,
      opacity: 1,
      dashArray: '4 2',   // any SVG dash pattern
    },
  },
]

width and opacity matter more than they look: a stroke drawn too heavy at the world scale reads as its own layer of information once the classes get close in color, especially with a sequential ramp on a low classes count.