Skip to content

Create and Style Clusters

This tutorial shows how to cluster large point datasets on your MapMetrics Android map for better performance and readability.

Prerequisites

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

OptionTypeDescription
withCluster(true)BooleanEnable clustering
withClusterMaxZoom(14)IntStop clustering above this zoom level
withClusterRadius(50)IntCluster merge radius in pixels

Layer Structure

LayerFilterPurpose
cluster-circleshas("point_count")Colored circles for clusters
cluster-counthas("point_count")Text label showing count
unclustered-pointnot(has("point_count"))Individual data points

Next Steps


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.