Skip to content
This site is a preview of the main branch. The published documentation is at maplibre.org.

Cluster points

Thousands of points drawn at once overlap and hide each other. A clustered GeoJSON source replaces each group of nearby points with one cluster point. As the user zooms in, clusters split into smaller clusters and then into single points.

1275
12
One cluster
75
Two clusters
Individual points

Set cluster = true in the options block of rememberGeoJsonSource:

App.kt
val state = rememberMapState {
val earthquakes =
rememberGeoJsonSource(
GeoJsonData.Uri(
"https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_month.geojson"
)
) {
cluster = true
clusterRadius = 40
clusterMaxZoom = 10
}
}
MaplibreMap(state = state)

GeoJsonOptions.clusterRadius sets how close points must be to join a cluster. A larger radius makes fewer, bigger clusters. GeoJsonOptions.clusterMaxZoom is the last zoom level with clusters. When the user zooms in further, the map shows every point.

A clustered source holds two kinds of point features. Each cluster has a point_count property with the number of points in it, and a point_count_abbreviated property with the same number shortened for labels, such as 1.2k. Single points keep their own properties and have no point_count.

Use feature.has("point_count") as a layer filter to draw each kind with its own layers:

App.kt
val state = rememberMapState {
val pointCount = feature["point_count"].asNumber()
// Clusters: bigger and warmer as they hold more points
CircleLayer(
id = "earthquake-clusters",
source = earthquakes,
filter = feature.has("point_count"),
radius = step(pointCount, const(14.dp), 25 to const(20.dp), 100 to const(28.dp)),
color =
step(
pointCount,
const(Color(0xFF4FC3F7)),
25 to const(Color(0xFFFFB74D)),
100 to const(Color(0xFFE57373)),
),
)
// The number of points on each cluster
SymbolLayer(
id = "earthquake-cluster-counts",
source = earthquakes,
filter = feature.has("point_count"),
textField = feature["point_count_abbreviated"].convertToString(),
// A font that the base style's glyphs provide
textFont = const(listOf("Noto Sans Regular")),
)
// Points that are not in a cluster
CircleLayer(
id = "earthquake-points",
source = earthquakes,
filter = !feature.has("point_count"),
radius = const(4.dp),
color = const(Color(0xFF4FC3F7)),
strokeWidth = const(1.dp),
strokeColor = const(Color.White),
)
}
MaplibreMap(state = state)

step picks a radius and a color for each range of point_count. Expressions in Kotlin explains step and filters. textFont must name a font that the base style provides. The OpenFreeMap styles provide Noto Sans Regular.

To add up a value from the points in each cluster, such as a total magnitude, set GeoJsonOptions.clusterProperties.

When the user clicks a cluster, you can zoom the map in until the cluster splits. GeoJsonSourceHandle.getClusterExpansionZoom returns that zoom level. In the map content, LocalMapState gives the map’s state. Get the source’s handle from mapState.style.sources, then move the camera to the cluster:

App.kt
val state = rememberMapState {
val mapState = checkNotNull(LocalMapState.current)
val scope = rememberCoroutineScope()
CircleLayer(
id = "earthquake-clusters",
source = earthquakes,
filter = feature.has("point_count"),
onClick = { features ->
val cluster = features.first()
scope.launch {
val handle = mapState.style.sources[earthquakes] ?: return@launch
val zoom = handle.getClusterExpansionZoom(cluster) ?: return@launch
val center = (cluster.geometry as? Point)?.coordinates ?: return@launch
mapState.animateCamera(CameraUpdate(center = center, zoom = zoom))
}
ClickResult.Consume
},
)
}
MaplibreMap(state = state)

The handle functions suspend, so the click handler launches a coroutine. Handle gestures and clicks covers layer click handlers. GeoJsonSourceHandle.getClusterLeaves returns the points inside a cluster, for example to list them in a panel.