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:

ShapeNotes
circle
square
diamond
triangle
star
cross
pinanchored 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',
  },
]
Premium feature

Point clustering is a Premium feature

Available on the Premium and OEM plans. Clustering works without a key for evaluation, with a watermark on the map; call ApexMaps.setLicense(key) to remove it.

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 },
  },
]
OptionTypeDefaultDescription
enabledbooleantrue when cluster is setTurn clustering off without removing the object
radiusnumber60Screen-space merge distance, in pixels
maxZoomnumber8Above this zoom, draw individual markers
minPointsnumber2Fewer members than this stay as individual markers
size[number, number]Radius range, in pixels, smallest to largest cluster count
colorstringFill for cluster circles
showCountbooleantrueShow the member count inside the circle
zoomOnClickbooleantrueFly 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.