Hexbin

Ten thousand markers on a country map is not a map of ten thousand things. Past the first overlap the ink stops tracking the number, so a reader gets the shape of the data and no way to rank one part of it against another. A hexbin lays a lattice over the projection and colors each cell by what landed in it, because a cell of fixed area can carry a number.

series: [
  {
    type: 'hexbin',
    name: 'Sightings',
    data: points,                              // { lon, lat } per point
    radius: 12,                                // screen pixels, center to vertex
    aggregate: 'count',                        // or sum, mean, min, max
    scale: { palette: 'viridis', classes: 6 },
  },
]

count is the default and needs no value field. "Where are these things" is the question that brings anyone here, and demanding a numeric column to answer it would be asking for a column the caller does not have.

Point input

Three coordinate shapes are accepted, because point data arrives in all three:

data: [
  { lon: -0.12, lat: 51.5 },        // lon first, matching GeoJSON
  { lng: -0.12, lat: 51.5 },        // for data that came from a [lat, lng] world
  { coordinates: [-0.12, 51.5] },   // for data that arrived as GeoJSON
]

There is no joinBy on a hexbin, and that is deliberate. A hexbin bins positions, so a datum with no position is dropped with a counted warning rather than joined to something. Binning region centroids would let the answer be decided by how the regions were drawn rather than by where anything happened, and it would change when the map changed. If your rows carry region keys rather than coordinates, you want a choropleth.

Options

OptionTypeDefaultWhat it controls
dataHexbinDatum[]The points
radiusnumber14Cell radius, center to vertex, in screen pixels
orientation'pointy' | 'flat''pointy'A vertex up, or a vertex to the side
aggregate'count' | 'sum' | 'mean' | 'min' | 'max''count'What the color encodes
valueFieldstring | (datum) => number'value'Field the aggregate reads. Ignored by count
minCountnumber1Bins holding fewer points than this are not drawn
gapnumber0Shrink each cell toward its center, as a fraction of the radius
scaleScaleOptionsHow the aggregate becomes a color

stroke, opacity, name and labels come from the shared series options.

The aggregate is the question

count and mean over the same points say different things, and the difference is usually the whole reason to pick one:

{ type: 'hexbin', data: points, aggregate: 'count' }                  // how many
{ type: 'hexbin', data: points, aggregate: 'mean', minCount: 5 }      // how much, on average

minCount is doing real work in the second. A mean over one point is that point, and it shouts as loudly as a mean over a hundred, so raising the floor is what keeps the average from being decided by its thinnest cells. An aggregate other than count that meets points with no number reports on the rest of the cell and says how many it skipped, in the debug warnings.

The radius is in screen pixels

That is what separates binning from drawing bigger hexagons: the cells stay the size you chose and the lattice refines as a reader zooms in rather than magnifying, so the resolution follows whoever is looking.

Cells are rebuilt at quantized zoom levels, the same policy clustering uses and shared with it, so a pan never re-bins and a smooth zoom crosses a level a handful of times rather than sixty times a second.

The color domain follows the bins

Smaller cells hold fewer points, so class breaks move down when the lattice refines, and the legend moves with them. The same color means a different number at a different zoom, and the legend says so rather than quietly lying.

scale: { palette: 'viridis', classes: 6 }                  // breaks follow the zoom
scale: { palette: 'viridis', classes: 6, domain: [0, 80] } // breaks pinned

Pin the domain when two maps have to be read against each other. Do not pin it as a default: fixed classes leave every cell in the palest class two zoom levels in. breaks pins explicit class edges the same way. Both are covered in Scales and Classification.

It sits on a basemap, not on your regions

A hexbin ignores boundaries rather than replacing them, so a map whose only series is a hexbin still draws a coastline underneath it: points floating in the void are not a map. There is nothing to declare for this.

const map = new ApexMaps(element, {
  geo: { map: 'us', fill: '#eef1f5' },
  series: [{ type: 'hexbin', name: 'Points per cell', data: points }],
})

Declaring an empty choropleth to get the same picture would also put its own empty scale in the legend, which is a caption for nothing.

Reading the lattice as cells

gap shrinks each cell toward its center as a fraction of the radius. Zero, the default, makes the cells share edges, which reads as one continuous surface. A small gap reads as a set of units:

{ type: 'hexbin', data: points, gap: 0.05, stroke: { color: '#ffffff', width: 0.4 } }
Premium feature

Hexbin is a Premium feature

Available on the Premium and OEM plans. A hexbin works without a key for evaluation, with a watermark on the map; call ApexMaps.setLicense(key) to remove it. It is priced with clustering rather than with the free point series, because both summarize points into an aggregate a reader cannot get back to the originals from.

Not the same thing as a hex tile layout

Two features draw hexagons and they are unrelated:

type: 'hexbin'layout: 'hex'
A cell isone patch of the projectionone region, placed by hand
It needspoints with coordinatesa region set with a layout
Cell countwhatever the data and the radius givefixed, however many regions there are
Boundariesignored by it, so it sits on a basemapreplaced by it
Reading ithow much landed wherewhich region, at equal weight

See also