Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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.MapLibreMap
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: MapLibreMap

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_map)

        mapView = findViewById(R.id.mapView)
        mapView.onCreate(savedInstanceState)
        mapView.getMapAsync { maplibreMap ->
            map = maplibreMap

            map.setStyle(
                Style.Builder().fromUri(
                    "https://gateway.mapmetrics-atlas.net/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-atlas.net/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.