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

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
- Create a map from a web map
PortalItem. - Create a
ClassBreaksRendererand define aFieldNameandDefaultSymbol.FieldNamemust be one of the summary fields in theAggregateFieldscollection. - Add
ClassBreakobjects each with an associatedSimpleMarkerSymbolto the renderer. - Create a
ClusteringFeatureReductionusing the renderer. - Add
AggregateFieldobjects to the feature reduction where theFieldNameis the name of the field to aggregate and theStatisticTypeis the type of aggregation to perform. - Define the
minSymbolSizeandmaxSymbolSizefor the feature reduction. If these are not defined they default to 12 and 70 respectively. - Add the
ClusteringFeatureReductionto theFeatureLayer. - Create a
LabelDefinitionwith aSimpleLabelExpressionandTextSymbolto define the cluster label. - Configure a
MapView.onSingleTapConfirmedevent and identify the nearest feature to display feature cluster information in aPopupViewer.
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
/* 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
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.configureclusters.screens.MainScreenimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // authentication with an API key or named user is // required to access basemaps and other location services ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN)
enableEdgeToEdge() setContent { SampleAppTheme { ConfigureClustersApp() } } }
@Composable private fun ConfigureClustersApp() { Surface( color = MaterialTheme.colorScheme.background ) { MainScreen( sampleName = getString(R.string.configure_clusters_app_name) ) } }}/* 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.screens
import androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.Spacerimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.navigationBarsPaddingimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.sizeimport androidx.compose.foundation.layout.widthimport androidx.compose.foundation.layout.wrapContentHeightimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Settingsimport androidx.compose.material.icons.rounded.Closeimport androidx.compose.material3.DropdownMenuItemimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.ExposedDropdownMenuBoximport androidx.compose.material3.ExposedDropdownMenuDefaultsimport androidx.compose.material3.FloatingActionButtonimport androidx.compose.material3.HorizontalDividerimport androidx.compose.material3.Iconimport androidx.compose.material3.IconButtonimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.MenuAnchorTypeimport androidx.compose.material3.ModalBottomSheetimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.SheetStateimport androidx.compose.material3.Switchimport androidx.compose.material3.Textimport androidx.compose.material3.TextFieldimport androidx.compose.material3.rememberModalBottomSheetStateimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableIntStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.runtime.saveable.rememberSaveableimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.esri.arcgismaps.sample.configureclusters.components.MapViewModelimport com.esri.arcgismaps.sample.sampleslib.components.BottomSheetimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBarimport com.esri.arcgismaps.sample.sampleslib.theme.SampleTypographyimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.launchimport kotlin.math.roundToInt
/** * Main screen layout for the sample app */@OptIn(ExperimentalMaterial3Api::class)@Composablefun MainScreen(sampleName: String) { // create a ViewModel to handle MapView interactions val mapViewModel: MapViewModel = viewModel()
val composableScope = rememberCoroutineScope()
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { Box( modifier = Modifier .fillMaxSize() .padding(it), contentAlignment = Alignment.Center ) { var mapScale by remember { mutableIntStateOf(0) } MapView( modifier = Modifier .fillMaxSize(), mapViewProxy = mapViewModel.mapViewProxy, // identify on single tap onSingleTapConfirmed = { singleTapConfirmedEvent -> mapViewModel.identify(singleTapConfirmedEvent) }, arcGISMap = mapViewModel.arcGISMap, // update the map scale in the UI on map scale change onMapScaleChanged = { currentMapScale -> if (!currentMapScale.isNaN()) { mapScale = currentMapScale.roundToInt() } }, )
val controlsBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) // show the "Show controls" button only when the bottom sheet is not visible if (!controlsBottomSheetState.isVisible) { FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 36.dp, end = 24.dp), onClick = { composableScope.launch { controlsBottomSheetState.show() } }, ) { Icon(Icons.Filled.Settings, contentDescription = "Show controls") } } if (controlsBottomSheetState.isVisible) { ClusterControlsBottomSheet( composableScope = composableScope, controlsBottomSheetState = controlsBottomSheetState, showClusterLabels = mapViewModel.showClusterLabels, updateClusterLabelState = mapViewModel::updateShowClusterLabelState, clusterRadiusOptions = mapViewModel.clusterRadiusOptions, clusterRadius = mapViewModel.clusterRadius, updateClusterRadiusState = mapViewModel::updateClusterRadiusState, clusterMaxScaleOptions = mapViewModel.clusterMaxScaleOptions, clusterMaxScale = mapViewModel.clusterMaxScale, updateClusterMaxScaleState = mapViewModel::updateClusterMaxScaleState, mapScale = mapScale ) } }
// display a bottom sheet to show popup details BottomSheet( isVisible = mapViewModel.showPopUpContent, bottomSheetContent = { ClusterInfoContent( popUpTitle = mapViewModel.popUpTitle, popUpInfo = mapViewModel.popUpInfo, onDismiss = { mapViewModel.updateShowPopUpContentState(false) } ) })
} )}
/** * Composable function to display the cluster controls bottom sheet. */@OptIn(ExperimentalMaterial3Api::class)@Composableprivate fun ClusterControlsBottomSheet( composableScope: CoroutineScope, controlsBottomSheetState: SheetState, showClusterLabels: Boolean, updateClusterLabelState: (Boolean) -> Unit, clusterRadiusOptions: List<Int>, clusterRadius: Int, updateClusterRadiusState: (Int) -> Unit, clusterMaxScaleOptions: List<Int>, clusterMaxScale: Int, updateClusterMaxScaleState: (Int) -> Unit, mapScale: Int,) { ModalBottomSheet( modifier = Modifier.wrapContentHeight(), sheetState = controlsBottomSheetState, onDismissRequest = { composableScope.launch { controlsBottomSheetState.hide() } }) { Column( Modifier .padding(12.dp) .navigationBarsPadding()) { Text( "Cluster labels visibility:", style = MaterialTheme.typography.titleMedium ) Spacer(Modifier.size(8.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text("Show labels") Switch( checked = showClusterLabels, onCheckedChange = { showClusterLabels -> updateClusterLabelState( showClusterLabels ) } ) } Spacer(Modifier.size(8.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text("Current map scale:") Text("1:$mapScale") } HorizontalDivider(Modifier.padding(vertical = 12.dp, horizontal = 8.dp)) Text( "Clustering properties:", style = MaterialTheme.typography.titleMedium ) Spacer(Modifier.size(8.dp)) ClusterRadiusControls( clusterRadiusOptions, clusterRadius, updateClusterRadiusState ) Spacer(Modifier.size(8.dp)) ClusterMaxScaleControls( clusterMaxScaleOptions, clusterMaxScale, updateClusterMaxScaleState ) } }}
/** * Composable function to display the cluster radius controls within the cluster controls bottom * sheet. */@OptIn(ExperimentalMaterial3Api::class)@Composableprivate fun ClusterRadiusControls( clusterRadiusOptions: List<Int>, clusterRadius: Int, updateClusterRadius: (Int) -> Unit) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( "Cluster radius", modifier = Modifier.padding(8.dp) ) var expanded by rememberSaveable { mutableStateOf(false) } ExposedDropdownMenuBox( modifier = Modifier.width(150.dp), expanded = expanded, onExpandedChange = { expanded = !expanded } ) { TextField( value = clusterRadius.toString(), onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable) ) ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { clusterRadiusOptions.forEachIndexed { index, clusterRadius -> DropdownMenuItem( text = { Text(clusterRadius.toString()) }, onClick = { updateClusterRadius(index) expanded = false }) // show a divider between dropdown menu options if (index < clusterRadiusOptions.lastIndex) { HorizontalDivider() } } } } }}
/** * Composable function to display the cluster max scale controls within the cluster controls bottom * sheet. */@OptIn(ExperimentalMaterial3Api::class)@Composableprivate fun ClusterMaxScaleControls( clusterMaxScaleOptions: List<Int>, clusterMaxScale: Int, updateClusterMaxScale: (Int) -> Unit) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( "Cluster max scale", modifier = Modifier.padding(8.dp) ) var expanded by rememberSaveable { mutableStateOf(false) } ExposedDropdownMenuBox( modifier = Modifier.width(150.dp), expanded = expanded, onExpandedChange = { expanded = !expanded } ) { TextField( value = clusterMaxScale.toString(), onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable) ) ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { clusterMaxScaleOptions.forEachIndexed { index, clusterRadius -> DropdownMenuItem( text = { Text(clusterRadius.toString()) }, onClick = { updateClusterMaxScale(index) expanded = false }) // show a divider between dropdown menu options if (index < clusterMaxScaleOptions.lastIndex) { HorizontalDivider() } } } } }}
/** * Composable function to display the cluster info content from the pop up within a bottom sheet. */@Composableprivate fun ClusterInfoContent( popUpTitle: String, popUpInfo: Map<String, Any?>, onDismiss: () -> Unit) { Column(Modifier.background(MaterialTheme.colorScheme.background)) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 30.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = popUpTitle.ifEmpty { "Cluster Info:" }, style = SampleTypography.headlineSmall ) IconButton( onClick = onDismiss ) { Icon( imageVector = Icons.Rounded.Close, contentDescription = "Close button" ) } } popUpInfo.forEach { Row( Modifier .fillMaxWidth() .padding( horizontal = 30.dp, vertical = 8.dp ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text(text = "${it.key}:", style = MaterialTheme.typography.labelMedium) Text(text = "${it.value}") } } Spacer(modifier = Modifier.size(24.dp)) }}/* 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.Logimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableIntStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.compose.ui.unit.dpimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.arcgisservices.LabelingPlacementimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.PortalItemimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.labeling.LabelDefinitionimport com.arcgismaps.mapping.labeling.SimpleLabelExpressionimport com.arcgismaps.mapping.layers.FeatureLayerimport com.arcgismaps.mapping.layers.Layerimport com.arcgismaps.mapping.popup.PopupDefinitionimport com.arcgismaps.mapping.reduction.AggregateFieldimport com.arcgismaps.mapping.reduction.AggregateStatisticTypeimport com.arcgismaps.mapping.reduction.ClusteringFeatureReductionimport com.arcgismaps.mapping.symbology.ClassBreakimport com.arcgismaps.mapping.symbology.ClassBreaksRendererimport com.arcgismaps.mapping.symbology.HorizontalAlignmentimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolimport com.arcgismaps.mapping.symbology.TextSymbolimport com.arcgismaps.mapping.symbology.VerticalAlignmentimport com.arcgismaps.mapping.view.SingleTapConfirmedEventimport com.arcgismaps.portal.Portalimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport 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 }}