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 a popup.
How it works
- Create a map from a web map
PortalItem. - Create a
ClassBreaksRendererand define a field name and default symbol. Field name must 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 the field name is the name of the field to aggregate and the statistic type is the type of aggregation to perform. - Define the min and max symbol sizes for 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. - Use the
onSingleTapGesturemodifier and callMapViewProxy.identify(on:screenPoint:tolerance:returnPopupsOnly:maximumResults:)to display the feature cluster’s attributes in a popup.
Relevant API
- AggregateGeoElement
- ClassBreaksRenderer
- FeatureLayer
- FeatureReduction
- GeoElement
- IdentifyLayerResult
- Popup
About the data
This sample uses a web map that displays residential data for Zurich, Switzerland.
Tags
aggregate, bin, cluster, group, merge, normalize, popup, reduce, renderer, summarize
Sample code
// Copyright 2023 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//// https://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.
import ArcGISimport ArcGISToolkitimport SwiftUI
struct ConfigureClustersView: View { /// The model for the sample. @StateObject private var model = Model()
/// The popup to be shown as the result of the layer identify operation. @State private var popup: Popup?
/// A Boolean value indicating whether the popup view is shown. @State private var showsPopup = false
/// A Boolean value indicating whether the settings view is presented. @State private var showsSettings = false
/// The map view's scale. @State private var mapViewScale = 0.0
var body: some View { MapViewReader { proxy in MapView(map: model.map) .onScaleChanged { scale in mapViewScale = scale } .onSingleTapGesture { screenPoint, _ in Task { guard let featureLayer = model.featureLayer, let result = try? await proxy.identify(on: featureLayer, screenPoint: screenPoint, tolerance: 12), !result.popups.isEmpty else { return } popup = result.popups.first showsPopup = popup != nil } } .popover(isPresented: $showsPopup, attachmentAnchor: .point(.bottom)) { [popup] in PopupView(root: popup!, isPresented: $showsPopup) .presentationDetents([.fraction(0.5)]) .frame(idealWidth: 320, idealHeight: 300) } .toolbar { ToolbarItem(placement: .bottomBar) { Button("Clustering Settings") { showsSettings = true } .popover(isPresented: $showsSettings) { [mapViewScale] in SettingsView(model: model, mapViewScale: mapViewScale) .presentationDetents([.fraction(0.5)]) .frame(idealWidth: 320, idealHeight: 340) } } } .task { await proxy.setViewpoint(Viewpoint(latitude: 47.38, longitude: 8.53, scale: 8e4)) try? await model.setup() } } }}
#Preview { NavigationStack { ConfigureClustersView() }}// Copyright 2023 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//// https://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.
import ArcGISimport UIKit.UIColor
extension ConfigureClustersView { /// The model used to store the geo model and other expensive objects /// used in this view. @MainActor class Model: ObservableObject { /// A Zurich buildings web map. let map = Map( item: PortalItem( portal: .arcGISOnline(connection: .anonymous), id: .zurichBuildings ) )
/// A custom feature reduction for dynamically aggregating and /// summarizing groups of features as the map scale changes. private let clusteringFeatureReduction = makeCustomFeatureReduction(renderer: makeClassBreaksRenderer())
/// The buildings feature layer in the web map. var featureLayer: FeatureLayer? { map.operationalLayers.first as? FeatureLayer }
/// A Boolean value indicating whether cluster labels are displayed. var showsLabels: Bool { didSet { clusteringFeatureReduction.showsLabels = showsLabels } }
/// The maximum scale of feature clusters. /// - Note: The default value for max scale is 0. var maxScale: Double { didSet { clusteringFeatureReduction.maxScale = maxScale } }
/// The radius of feature clusters. /// - Note: The default value for cluster radius is 60. /// Larger radius allows more features to be grouped into a cluster. var radius: Double { didSet { clusteringFeatureReduction.radius = radius } }
init() { // Set initial values for controls. showsLabels = clusteringFeatureReduction.showsLabels radius = clusteringFeatureReduction.radius maxScale = clusteringFeatureReduction.maxScale ?? .zero }
/// Loads the web map and set up the feature layer. func setup() async throws { try await map.load() featureLayer?.featureReduction = clusteringFeatureReduction }
/// Creates a class breaks renderer for the custom feature reduction /// - Returns: A `ClassBreaksRenderer` object. private static func makeClassBreaksRenderer() -> ClassBreaksRenderer { // For each feature cluster with a given average building height, // a color is assigned to each symbol. let colors = [ (4, 251, 255), (44, 211, 255), (74, 181, 255), (120, 135, 255), (165, 90, 255), (194, 61, 255), (224, 31, 255), (254, 1, 255) ].map { UIColor(red: $0.0 / 255, green: $0.1 / 255, blue: $0.2 / 255, alpha: 1) }
// Create a class break and a symbol to display the features // in each value range. // In this case, the average building height ranges from 0 to 7 stories. let classBreaks = zip([Int](0...7), colors).map { value, color in ClassBreak( description: "\(value) floor", label: String(value), minValue: Double(value), maxValue: Double(value) + 1, symbol: SimpleMarkerSymbol(color: color) ) }
// Create a class breaks renderer to apply to the custom // feature reduction. // 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. let renderer = ClassBreaksRenderer(fieldName: "Average Building Height", classBreaks: classBreaks)
// Create a default symbol for features that do not fall within // any of the ranges defined by the class breaks. renderer.defaultSymbol = SimpleMarkerSymbol(color: .systemPink)
return renderer }
/// Creates a custom feature reduction for the sample. /// - Parameter renderer: A renderer for drawing clustered features. /// - Returns: A `ClusteringFeatureReduction` object. private static func makeCustomFeatureReduction(renderer: ClassBreaksRenderer) -> ClusteringFeatureReduction { // Create a new clustering feature reduction using the // class breaks renderer. let clusteringFeatureReduction = ClusteringFeatureReduction(renderer: renderer)
// Set the feature reduction's aggregate fields. // Note that the field names must match those in the feature layer. // The aggregate fields summarize values based on the defined // aggregate statistic type. clusteringFeatureReduction.addAggregateFields([ AggregateField( name: "Total Residential Buildings", statisticFieldName: "Residential_Buildings", statisticType: .sum ), AggregateField( name: "Average Building Height", statisticFieldName: "Most_common_number_of_storeys", statisticType: .mode ) ])
// Enable the feature reduction. clusteringFeatureReduction.isEnabled = true
// Set the popup definition for the custom feature reduction. clusteringFeatureReduction.popupDefinition = PopupDefinition(popupSource: clusteringFeatureReduction)
// 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. clusteringFeatureReduction.minSymbolSize = 5 clusteringFeatureReduction.maxSymbolSize = 90
// Create a label definition with a simple label expression. let labelDefinition = LabelDefinition( labelExpression: SimpleLabelExpression(simpleExpression: "[cluster_count]"), textSymbol: TextSymbol(color: .black, size: 15) ) labelDefinition.placement = .pointCenterCenter
// Add the label definition to the feature reduction. clusteringFeatureReduction.addLabelDefinition(labelDefinition)
return clusteringFeatureReduction } }}
private extension PortalItem.ID { /// The ID used in the Zurich buildings web map. static var zurichBuildings: Self { Self("aa44e79a4836413c89908e1afdace2ea")! }}// Copyright 2023 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//// https://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.
import SwiftUI
extension ConfigureClustersView { struct SettingsView: View { /// The model for the sample. @ObservedObject var model: Model
/// The action to dismiss the settings sheet. @Environment(\.dismiss) private var dismiss: DismissAction
/// The map view's scale. let mapViewScale: Double
/// The radius of feature clusters selected by the user. @State private var selectedRadius = 60
/// The maximum scale of feature clusters selected by the user. @State private var selectedMaxScale = 0
var body: some View { NavigationStack { Form { Section("Cluster Labels Visibility") { Toggle("Show Labels", isOn: $model.showsLabels) .toggleStyle(.switch) }
Section("Clustering Properties") { Picker("Cluster Radius", selection: $selectedRadius) { ForEach([30, 45, 60, 75, 90], id: \.self) { radius in Text("\(radius)") } } .onChange(of: selectedRadius) { model.radius = Double(selectedRadius) }
Picker("Cluster Max Scale", selection: $selectedMaxScale) { ForEach([0, 1000, 5000, 10000, 50000, 100000, 500000], id: \.self) { scale in Text(("\(scale)")) } } .onChange(of: selectedMaxScale) { model.maxScale = Double(selectedMaxScale) }
LabeledContent( "Current Map Scale", value: mapViewScale, format: .number.precision(.fractionLength(0)) ) } } .navigationTitle("Clustering Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } } }}