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
| Option | Type | Default | What it controls |
|---|---|---|---|
data | HexbinDatum[] | The points | |
radius | number | 14 | Cell 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 |
valueField | string | (datum) => number | 'value' | Field the aggregate reads. Ignored by count |
minCount | number | 1 | Bins holding fewer points than this are not drawn |
gap | number | 0 | Shrink each cell toward its center, as a fraction of the radius |
scale | ScaleOptions | How 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 } }
Not the same thing as a hex tile layout
Two features draw hexagons and they are unrelated:
type: 'hexbin' | layout: 'hex' | |
|---|---|---|
| A cell is | one patch of the projection | one region, placed by hand |
| It needs | points with coordinates | a region set with a layout |
| Cell count | whatever the data and the radius give | fixed, however many regions there are |
| Boundaries | ignored by it, so it sits on a basemap | replaced by it |
| Reading it | how much landed where | which region, at equal weight |
See also
- Markers and Clustering for the other answer to dense points, which keeps them as points
- Bubbles for proportional symbols at known locations
- Scales and Classification for
scale,domainandbreaks - Hex Tile Layouts for the other hexagons
- Performance for what drawing every point costs instead