Configure clusters

View on GitHubSample viewer app

Add client side feature reduction on a point feature layer that is not pre-configured with clustering.

Image of configure clusters

Use case

Feature clustering can be used to dynamically aggregate groups of points that are within proximity of each other in order to represent each group with a single symbol. Such grouping allows you to see patterns in the data that are difficult to visualize when a layer contains hundreds or thousands of points that overlap and cover each other. Users can add feature clustering to point feature layers. This is useful when the layer does not have the feature reduction defined or when the existing feature reduction properties need to be overridden.

How to use the sample

Interact with the controls to customize clustering feature reduction properties. Tap on any clustered aggregate geoelement to see the cluster feature count and aggregate fields in the popup.

How it works

  1. Create a map from a web map PortalItem.
  2. Create a ClassBreaksRenderer and define a FieldName and DefaultSymbol. FieldName must be one of the summary fields in the AggregateFields collection.
  3. Add ClassBreak objects each with an associated SimpleMarkerSymbol to the renderer.
  4. Create a ClusteringFeatureReduction using the renderer.
  5. Add AggregateField objects to the feature reduction where the FieldName is the name of the field to aggregate and the StatisticType is the type of aggregation to perform.
  6. Define the minSymbolSize and maxSymbolSize for the feature reduction. If these are not defined they default to 12 and 70 respectively.
  7. Add the ClusteringFeatureReduction to the FeatureLayer.
  8. Create a LabelDefinition with a SimpleLabelExpression and TextSymbol to define the cluster label.
  9. Configure a MapView.onSingleTapConfirmed event and identify the nearest feature to display feature cluster information in a PopupViewer.

Relevant API

  • AggregateGeoElement
  • ClassBreaksRenderer
  • FeatureLayer
  • FeatureReduction
  • GeoElement
  • IdentifyLayerResult
  • PopupViewer

About the data

This sample uses a web map that displays residential data for Zurich, Switzerland.

Additional information

This sample uses the GeoView-Compose Toolkit module to be able to implement a composable MapView.

Tags

aggregate, bin, cluster, geoview-compose, group, merge, normalize, popup, reduce, renderer, summarize, toolkit

Sample Code

MapViewModel.ktMapViewModel.ktMainActivity.ktMainScreen.kt
Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
/* Copyright 2024 Esri
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */

package com.esri.arcgismaps.sample.configureclusters.components

import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arcgismaps.Color
import com.arcgismaps.arcgisservices.LabelingPlacement
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.PortalItem
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.mapping.labeling.LabelDefinition
import com.arcgismaps.mapping.labeling.SimpleLabelExpression
import com.arcgismaps.mapping.layers.FeatureLayer
import com.arcgismaps.mapping.layers.Layer
import com.arcgismaps.mapping.popup.PopupDefinition
import com.arcgismaps.mapping.reduction.AggregateField
import com.arcgismaps.mapping.reduction.AggregateStatisticType
import com.arcgismaps.mapping.reduction.ClusteringFeatureReduction
import com.arcgismaps.mapping.symbology.ClassBreak
import com.arcgismaps.mapping.symbology.ClassBreaksRenderer
import com.arcgismaps.mapping.symbology.HorizontalAlignment
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol
import com.arcgismaps.mapping.symbology.TextSymbol
import com.arcgismaps.mapping.symbology.VerticalAlignment
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
import com.arcgismaps.portal.Portal
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
import kotlinx.coroutines.launch


class MapViewModel : ViewModel() {

    private val clusteringFeatureReduction = createCustomFeatureReduction()

    // Create a mapViewProxy that will be used to identify features in the MapView.
    // This should also be passed to the composable MapView this mapViewProxy is associated with.
    val mapViewProxy = MapViewProxy()

    // Keep track of the feature layer that will be used to identify features in the MapView.
    private var featureLayer: FeatureLayer? = null

    // Create a map with a feature layer that contains building data.
    val arcGISMap = ArcGISMap(
        PortalItem(
            Portal("https://www.arcgis.com"),
            "aa44e79a4836413c89908e1afdace2ea"
        )
    ).apply {
        initialViewpoint = Viewpoint(47.38, 8.53, 8e4)
        viewModelScope.launch {
            load().onSuccess {
                // Apply the custom feature reduction to the first feature layer.
                featureLayer = operationalLayers.first() as FeatureLayer
                featureLayer?.featureReduction = clusteringFeatureReduction
            }.onFailure {
                Log.e("MapViewModel", "Failed to load feature layer", it)
            }
        }
    }


    private fun createCustomFeatureReduction(): ClusteringFeatureReduction {
        // Create a class breaks renderer to apply to the custom feature reduction.
        val classBreaksRenderer = ClassBreaksRenderer().apply {
            // Define the field to use for the class breaks renderer.
            // Note that this field name must match the name of an aggregate field contained in the clustering feature reduction's aggregate fields property.
            fieldName = "Average Building Height"
            val colors = listOf(
                Color.fromRgba(4, 251, 255),
                Color.fromRgba(44, 211, 255),
                Color.fromRgba(74, 181, 255),
                Color.fromRgba(120, 135, 255),
                Color.fromRgba(165, 90, 255),
                Color.fromRgba(194, 61, 255),
                Color.fromRgba(224, 31, 255),
                Color.fromRgba(254, 1, 255)
            )
            // Add a class break for each intended value range and define a symbol to display for features in that range.
            // In this case, the average building height ranges from 0 to 8 storeys.
            // For each cluster of features with a given average building height, a symbol is defined with a specified color.
            for (i in 0..7) {
                classBreaks.add(
                    ClassBreak(
                        i.toString(),
                        i.toString(),
                        i.toDouble(),
                        (i + 1).toDouble(),
                        SimpleMarkerSymbol().apply { color = colors[i] })
                )
            }

            // Define a default symbol to use for features that do not fall within any of the ranges defined by the class breaks.
            defaultSymbol = SimpleMarkerSymbol().apply { color = Color.red }
        }

        // Create a new clustering feature reduction using the class breaks renderer.
        return ClusteringFeatureReduction(classBreaksRenderer).apply {
            // Set the feature reduction's aggregate fields. Note that the field names must match the names of fields in the feature layer's dataset.
            // The aggregate fields summarize values based on the defined aggregate statistic type.
            aggregateFields.add(
                AggregateField(
                    "Total Residential Buildings",
                    "Residential_Buildings",
                    AggregateStatisticType.Sum
                )
            )
            aggregateFields.add(
                AggregateField(
                    "Average Building Height",
                    "Most_common_number_of_storeys",
                    AggregateStatisticType.Mode
                )
            )

            // Enable the feature reduction.
            isEnabled = true

            // Create a label definition with a simple label expression.
            val simpleLabelExpression = SimpleLabelExpression("[cluster_count]")
            val textSymbol = TextSymbol(
                "",
                Color.black,
                12.0f,
                HorizontalAlignment.Center,
                VerticalAlignment.Middle
            )
            val labelDefinition = LabelDefinition(simpleLabelExpression, textSymbol).apply {
                placement = LabelingPlacement.PointCenterCenter
            }

            // Add the label definition to the feature reduction.
            labelDefinitions.add(labelDefinition)

            // Set the popup definition for the custom feature reduction.
            popupDefinition = PopupDefinition(this)
            // Set values for the feature reduction's cluster minimum and maximum symbol sizes.
            // Note that the default values for Max and Min symbol size are 70 and 12 respectively.
            minSymbolSize = 5.0
            maxSymbolSize = 90.0
        }

    }

    /**
     * Identifies the tapped screen coordinate in the provided [singleTapConfirmedEvent]
     */
    fun identify(singleTapConfirmedEvent: SingleTapConfirmedEvent) {
        viewModelScope.launch {
            // identify the cluster in the feature layer on the tapped coordinate
            mapViewProxy.identify(
                featureLayer as Layer,
                screenCoordinate = singleTapConfirmedEvent.screenCoordinate,
                tolerance = 12.dp,
                returnPopupsOnly = true,
                maximumResults = 1
            ).onSuccess {
                if (it.popups.isEmpty()) {
                    updateShowPopUpContentState(false)
                } else {
                    updateShowPopUpContentState(true)
                    updatePopUpTitleState(it.popups.first().title)
                    updatePopUpInfoState(it.popups.first().geoElement.attributes)
                }
            }
        }
    }

    var showClusterLabels by mutableStateOf(true)
        private set

    fun updateShowClusterLabelState(show: Boolean) {
        showClusterLabels = show
        clusteringFeatureReduction.showLabels = showClusterLabels
    }

    // Note that the default value for cluster radius is 60.
    // Increasing the cluster radius increases the number of features that are grouped together into a cluster.
    val clusterRadiusOptions = listOf(30, 45, 60, 75, 90)
    var clusterRadius by mutableIntStateOf(clusterRadiusOptions[2])
        private set

    fun updateClusterRadiusState(index: Int) {
        clusterRadius = clusterRadiusOptions[index]
        clusteringFeatureReduction.radius = clusterRadius.toDouble()
    }

    // Note that the default value for max scale is 0.
    // The max scale value is the maximum scale at which clustering is applied.
    val clusterMaxScaleOptions = listOf(0, 1000, 5000, 10000, 50000, 100000, 500000)
    var clusterMaxScale by mutableIntStateOf(clusterMaxScaleOptions[0])
        private set

    fun updateClusterMaxScaleState(index: Int) {
        clusterMaxScale = clusterMaxScaleOptions[index]
        clusteringFeatureReduction.maxScale = clusterMaxScale.toDouble()
    }

    var showPopUpContent by mutableStateOf(false)
        private set

    fun updateShowPopUpContentState(show: Boolean) {
        showPopUpContent = show
    }

    var popUpTitle by mutableStateOf("")
        private set

    private fun updatePopUpTitleState(title: String) {
        popUpTitle = title
    }

    var popUpInfo by mutableStateOf<Map<String, Any?>>(emptyMap())
        private set

    private fun updatePopUpInfoState(info: Map<String, Any?>) {
        popUpInfo = info
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.