Markers and Clustering
A marker says "something is here", so its size is fixed. The moment size varies, a reader starts decoding it as a quantity, and that's the bubble series, which scales by area and ships a legend built for exactly that.
series: [
{
type: 'marker',
name: 'Sites',
data: [{ name: 'Depot 4', lon: -0.12, lat: 51.5, kind: 'depot' }],
shape: 'pin', // circle, square, diamond, triangle, star, cross, pin
colorBy: 'kind', // ordinal scale plus a legend, automatically
},
]
Seven shapes
Every shape is a generated path, so it scales cleanly at any size with no sprite sheet, icon font, or image load that can fail CORS:
| Shape | Notes |
|---|---|
circle | |
square | |
diamond | |
triangle | |
star | |
cross | |
pin | anchored at its point, not its center |
shape on the series sets one shape for the whole series, or pass a function of the datum to vary it. Individual rows can also override it with shape directly on the datum, alongside per-point color and size overrides.
shape: (datum) => (datum.kind === 'depot' ? 'square' : 'pin')
size is a fixed pixel width for the shape's bounding box, 10 by default. It's deliberately not driven by a value: that's what makes it a marker rather than a bubble.
The pin's anchor
Every shape except pin is centered on its coordinate. pin draws the teardrop shape everyone expects from a map, anchored at the point of the teardrop rather than its bounding-box center, because a pin floating above the place it marks is a pin pointing at nothing.
Hit targets smaller than a pointer
Every mark gets an invisible hit circle, sized generously enough that a 6px star doesn't demand pixel-perfect aim. interaction.nearest extends the same idea across the whole map: a pointer within a set radius of any point mark hovers and clicks it as if it were directly on it, with the nearest mark winning ties.
colorBy
colorBy names the field on each datum that groups markers into categories, an ordinal scale plus a legend generated automatically, the same way a choropleth's scale and legend come from its data:
series: [
{
type: 'marker',
name: 'Sites',
data: sites, // { name, lon, lat, kind }
colorBy: 'kind',
palette: 'tableau',
},
]
Clustering
Clustering is an option on the marker series, not a series of its own. The data is identical either way: clustering is a decision about how to draw points that would otherwise pile up, in the same way classification is a decision about how to color values. A separate series type would fork position resolution, hit testing, coloring and the legend, and would force you to swap series types at a zoom threshold.
series: [
{
type: 'marker',
name: 'Sites',
data: sites,
colorBy: 'kind',
cluster: { radius: 55, maxZoom: 6 },
},
]
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true when cluster is set | Turn clustering off without removing the object |
radius | number | 60 | Screen-space merge distance, in pixels |
maxZoom | number | 8 | Above this zoom, draw individual markers |
minPoints | number | 2 | Fewer members than this stay as individual markers |
size | [number, number] | Radius range, in pixels, smallest to largest cluster count | |
color | string | Fill for cluster circles | |
showCount | boolean | true | Show the member count inside the circle |
zoomOnClick | boolean | true | Fly to the bounds of a cluster's members on click |
Three things it does that a quick implementation usually doesn't:
- Merges by distance, not by grid cell. Bucketing points into cells is simpler, but it separates two points a few pixels apart that happen to straddle a boundary while merging two at opposite corners of one cell. Readers notice, because the map contradicts what they can see. A grid is used only as a neighbor index; the merge test is real distance.
- Clusters in world space at quantized zoom levels. Panning never reclusters, so counts never shimmer or renumber under the cursor, and a smooth pinch recomputes a handful of times instead of sixty times a second.
- Places each cluster at its members' center of mass, sized by the square root of the member count, so a cluster of 100 doesn't read as a hundred times a cluster of 1.
Clicking a cluster flies to the bounds of its members, and emits clusterClick first if you'd rather do something else:
map.on('clusterClick', ({ value, datum }) => {
console.log(`cluster of ${value} sites`)
})
Members that share the exact same position would otherwise give a zero-size box and ask the camera for infinite zoom, so that case steps in with a fixed zoom amount instead.