Bubble Maps

A choropleth colors polygons, so a large country reads as important whether or not it actually is. The bubble series exists for the opposite case: absolute magnitude, drawn as a circle whose area is independent of the geometry sitting underneath it.

series: [
  {
    type: 'bubble',
    name: 'Metro population',
    data: [
      { name: 'Tokyo', lon: 139.69, lat: 35.69, value: 37_400_068 },
      { name: 'Delhi', lon: 77.21, lat: 28.61, value: 28_514_000 },
    ],
  },
]

Square-root sizing, and why it's the default

size.scale defaults to 'sqrt': radius is proportional to the square root of value, so a circle's area, the thing a reader actually perceives, ends up proportional to the value. Switch it to 'linear' and radius itself becomes proportional to value, which means area grows with the square of it: a city twice the size paints four times the ink, and every comparison on the map is overstated. 'linear' is available for publications that standardize on it, and reaching for it prints a dev-mode warning.

size: { scale: 'sqrt', range: [3, 30] }   // 'sqrt' is the default

size.range

range is [minRadius, maxRadius] in screen pixels, the radii assigned to the smallest and largest values in the data (or to size.domain, if you fix one explicitly rather than letting it come from the data). Circles hold that pixel size across zoom, because a radius that grew with the camera would stop encoding the value it's supposed to carry.

Largest-first paint order

sortBySize defaults to true: bubbles draw largest first, so a small circle sitting near a big one lands on top of it instead of underneath. Turning it off is almost always a mistake, since it's the difference between every small bubble staying clickable and the biggest one on the map burying the rest.

A second color encoding

A single fill (color) is the default, but you can drive color from a second variable with colorField and colorScale:

{
  type: 'bubble',
  name: 'Metro population',
  data: cities,
  size: { scale: 'sqrt', range: [3, 30] },
  colorField: 'growth',
  colorScale: { palette: 'rdylgn', classes: 5 },
}

Size still carries the primary value; color layers a second one on top, classed the same way a choropleth's scale is.

The size legend

The legend nests three reference circles at round values rather than a swatch list, because round-value nested circles are the one bubble legend a reader can actually decode: a list of nine exact radii is not. It's generated automatically from size.range and the data's domain, so there's nothing separate to configure for it.

Where positions come from

BubbleDatum reads coordinates straight off each row, lon / lat (or lng as a synonym for lon, for data that already speaks [lat, lng]):

data: [{ name: 'Tokyo', lon: 139.69, lat: 35.69, value: 37_400_068 }]

When your rows have no coordinates at all, join them to geometry instead and let ApexMaps place each bubble at that feature's centroid, using the same joinBy shape a choropleth uses:

{
  type: 'bubble',
  name: 'Population by country',
  joinBy: ['iso_a3', 'code'],
  data: [{ code: 'JPN', value: 125_000_000 }],
}