Create and Style Clusters
This tutorial shows how to cluster large point datasets on your MapMetrics Android map for better performance and readability.
Prerequisites
- Completed the Getting Started Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Basic Clustering
Group nearby points into clusters that show a count:
kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonOptions
import org.maplibre.android.style.sources.GeoJsonSource
import java.net.URI
class ClusterActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addClusters(style)
}
}
}
private fun addClusters(style: Style) {
// Add GeoJSON source with clustering enabled
style.addSource(
GeoJsonSource(
"earthquake-source",
URI("https://gateway.mapmetrics.org/assets/earthquakes.geojson"),
GeoJsonOptions()
.withCluster(true)
.withClusterMaxZoom(14)
.withClusterRadius(50)
)
)
// Layer 1: Cluster circles — sized and colored by point count
style.addLayer(
CircleLayer("cluster-circles", "earthquake-source")
.withProperties(
// Color by cluster size
circleColor(
step(
get("point_count"),
color(Color.parseColor("#51bbd6")), // < 100
stop(100, color(Color.parseColor("#f1f075"))),
stop(750, color(Color.parseColor("#f28cb1")))
)
),
// Radius by cluster size
circleRadius(
step(
get("point_count"),
literal(20), // < 100: 20px
stop(100, 30), // 100-749: 30px
stop(750, 40) // 750+: 40px
)
),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(2f)
)
.withFilter(has("point_count")) // Only clusters
)
// Layer 2: Cluster count labels
style.addLayer(
SymbolLayer("cluster-count", "earthquake-source")
.withProperties(
textField(toString(get("point_count"))),
textSize(12f),
textColor(Color.BLACK),
textIgnorePlacement(true),
textAllowOverlap(true)
)
.withFilter(has("point_count"))
)
// Layer 3: Unclustered individual points
style.addLayer(
CircleLayer("unclustered-point", "earthquake-source")
.withProperties(
circleColor(Color.parseColor("#11b4da")),
circleRadius(6f),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(1f)
)
.withFilter(Expression.not(has("point_count")))
)
// Set initial view
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(20.0, 0.0))
.zoom(2.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}Click to Expand Clusters
Zoom in when the user taps on a cluster:
kotlin
private fun setupClusterClick() {
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
val features = map.queryRenderedFeatures(screenPoint, "cluster-circles")
if (features.isNotEmpty()) {
val cluster = features[0]
val pointCount = cluster.getNumberProperty("point_count").toInt()
// Zoom in by 2 levels toward the cluster
map.animateCamera(
org.maplibre.android.camera.CameraUpdateFactory.newLatLngZoom(
latLng,
map.cameraPosition.zoom + 2
),
500
)
}
true
}
}Click to Show Unclustered Point Info
Show details when tapping an individual point:
kotlin
private fun setupPointClick() {
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
// Check unclustered points first
val pointFeatures = map.queryRenderedFeatures(screenPoint, "unclustered-point")
if (pointFeatures.isNotEmpty()) {
val feature = pointFeatures[0]
val mag = feature.getNumberProperty("mag")
val place = feature.getStringProperty("place")
map.addMarker(
org.maplibre.android.annotations.MarkerOptions()
.position(latLng)
.title("Magnitude: $mag")
.snippet(place ?: "Unknown location")
)
return@addOnMapClickListener true
}
// Then check clusters
val clusterFeatures = map.queryRenderedFeatures(screenPoint, "cluster-circles")
if (clusterFeatures.isNotEmpty()) {
map.animateCamera(
org.maplibre.android.camera.CameraUpdateFactory.newLatLngZoom(
latLng, map.cameraPosition.zoom + 2
),
500
)
}
true
}
}Cluster Options
| Option | Type | Description |
|---|---|---|
withCluster(true) | Boolean | Enable clustering |
withClusterMaxZoom(14) | Int | Stop clustering above this zoom level |
withClusterRadius(50) | Int | Cluster merge radius in pixels |
Layer Structure
| Layer | Filter | Purpose |
|---|---|---|
cluster-circles | has("point_count") | Colored circles for clusters |
cluster-count | has("point_count") | Text label showing count |
unclustered-point | not(has("point_count")) | Individual data points |
Next Steps
- Add a Heatmap — Density-based visualization
- Circle Layer — Styled data points
- GeoJSON Guide — Working with GeoJSON data
Tip: Adjust clusterRadius based on your data density — use 30-40 for sparse data, 60-80 for dense data. Higher radius creates fewer, larger clusters.