Clustering
Point data dense enough to clutter the map can be clustered: spatially close points collapse into a single marker, per zoom level, usually labelled with how many it stands for. Clustering 100,000 points takes well under a second on a mid-range phone.
You control:
- the style of the cluster object — including generating it from the count;
- the minimum distance, in pixels, before two objects merge;
- the minimum zoom clusters form at;
- what a tap does — zoom in, or expand the cluster in place (useful up to about four objects).
The layer
ClusteredVectorLayer wraps a VectorDataSource and a cluster element builder. The source
must hold all the elements, not just those in the current view — clustering is done over the
whole set, not the visible one.
- Java
- Swift
LocalVectorDataSource source = new LocalVectorDataSource(mapView.getOptions().getBaseProjection());
// … add every Marker to `source` …
ClusteredVectorLayer layer = new ClusteredVectorLayer(source, new CountClusterBuilder());
layer.setMinimumClusterDistance(20); // screen pixels
mapView.getLayers().add(layer);
let source = MSFLocalVectorDataSource(projection: mapView.getOptions().getBaseProjection())
// … add every MSFMarker to `source` …
let layer = MSFClusteredVectorLayer(dataSource: source, clusterElementBuilder: CountClusterBuilder())
layer?.setMinimumClusterDistance(20)
mapView.getLayers()?.add(layer)
The builder
buildClusterElement is handed the cluster's position and the elements inside it, and returns the
one element that replaces them — a Marker, a Point, a BalloonPopup, anything.
Cache the styles. The builder is called for every cluster on every zoom change; rendering a bitmap per call is what makes clustering feel slow. One style per count is enough:
- Java
- Swift
class CountClusterBuilder extends ClusterElementBuilder {
private final Map<Integer, MarkerStyle> styles = new HashMap<>();
private final android.graphics.Bitmap base;
@Override
public VectorElement buildClusterElement(MapPos pos, VectorElementVector elements) {
int count = (int) elements.size();
// A cluster of one is the original marker, with the original style.
if (count == 1) {
return new Marker(pos, ((Marker) elements.get(0)).getStyle());
}
MarkerStyle style = styles.get(count);
if (style == null) {
android.graphics.Bitmap canvasBitmap =
base.copy(android.graphics.Bitmap.Config.ARGB_8888, true);
android.graphics.Canvas canvas = new android.graphics.Canvas(canvasBitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setTextAlign(Paint.Align.CENTER);
paint.setTextSize(12);
canvas.drawText(Integer.toString(count), base.getWidth() / 2, base.getHeight() / 2 - 5, paint);
MarkerStyleBuilder builder = new MarkerStyleBuilder();
builder.setBitmap(BitmapUtils.createBitmapFromAndroidBitmap(canvasBitmap));
builder.setSize(30);
// Bigger clusters win a placement contest against smaller ones.
builder.setPlacementPriority(-count);
style = builder.buildStyle();
styles.put(count, style);
}
return new Marker(pos, style);
}
}
class CountClusterBuilder: MSFClusterElementBuilder {
private var styles: [Int: MSFMarkerStyle] = [:]
override func buildClusterElement(_ pos: MSFMapPos, elements: MSFVectorElementVector) -> MSFVectorElement {
let count = Int(elements.size())
if count == 1, let marker = elements.get(0) as? MSFMarker {
return MSFMarker(pos: pos, style: marker.getStyle())
}
if styles[count] == nil {
let builder = MSFMarkerStyleBuilder()
builder?.setBitmap(renderCountBitmap(count)) // your own drawing
builder?.setSize(30)
builder?.setPlacementPriority(Int32(-count))
styles[count] = builder?.buildStyle()
}
return MSFMarker(pos: pos, style: styles[count])
}
}
The builder runs on a background thread, so it must not touch UI state directly.
ClusteredVectorLayer takes a builder callback, which a JSON spec cannot express. Construct it
with the object API and adopt it:
MassifLayer clustered = Massif.adopt("layer", "sites", layer);
clustered.set("opacity", 0.9);
When not to cluster
Clustering exists to keep markers readable. For a large dataset that is really map data — tracks, a POI database, a route network — tile it through the GeoJSON vector tiling pyramid and style it with CartoCSS instead. That gets zoom-dependent filtering, label collision and the renderer's own batching, none of which a marker layer has.
Related: vector objects for the elements themselves, and performance for the spatial-index and simplification knobs.