Skip to content

Commit 4f7a354

Browse files
authored
Merge pull request #302 from Esri/Ting/FeatureReduction
[New] Add clustering feature reduction to a point feature layer
2 parents 0ef7a90 + 9de1e9e commit 4f7a354

File tree

7 files changed

+453
-0
lines changed

7 files changed

+453
-0
lines changed

Samples.xcodeproj/project.pbxproj

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
00B04273282EC59E0072E1B4 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B04272282EC59E0072E1B4 /* AboutView.swift */; };
5353
00B042E8282EDC690072E1B4 /* SetBasemapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B042E5282EDC690072E1B4 /* SetBasemapView.swift */; };
5454
00B04FB5283EEBA80026C882 /* DisplayOverviewMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B04FB4283EEBA80026C882 /* DisplayOverviewMapView.swift */; };
55+
00B56F792B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */; };
56+
00B56F7B2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */; };
57+
00B56F7D2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */; };
58+
00B56F7E2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */; };
59+
00B56F7F2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */; };
60+
00B56F802B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */; };
5561
00C43AED2947DC350099AE34 /* ArcGISToolkit in Frameworks */ = {isa = PBXBuildFile; productRef = 00C43AEC2947DC350099AE34 /* ArcGISToolkit */; };
5662
00C7993B2A845AAF00AFE342 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00C7993A2A845AAF00AFE342 /* Sidebar.swift */; };
5763
00C94A0D28B53DE1004E42D9 /* raster-file in Resources */ = {isa = PBXBuildFile; fileRef = 00C94A0C28B53DE1004E42D9 /* raster-file */; settings = {ASSET_TAGS = (AddRasterFromFile, ); }; };
@@ -380,6 +386,9 @@
380386
dstPath = "";
381387
dstSubfolderSpec = 7;
382388
files = (
389+
00B56F7E2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Copy Source Code Files */,
390+
00B56F7F2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Copy Source Code Files */,
391+
00B56F802B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Copy Source Code Files */,
383392
D73FCFFA2B02A3C50006360D /* FindAddressWithReverseGeocodeView.swift in Copy Source Code Files */,
384393
D742E4952B04134C00690098 /* DisplayWebSceneFromPortalItemView.swift in Copy Source Code Files */,
385394
D7010EC12B05618400D43F55 /* DisplaySceneFromMobileScenePackageView.swift in Copy Source Code Files */,
@@ -545,6 +554,9 @@
545554
00B04272282EC59E0072E1B4 /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
546555
00B042E5282EDC690072E1B4 /* SetBasemapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetBasemapView.swift; sourceTree = "<group>"; };
547556
00B04FB4283EEBA80026C882 /* DisplayOverviewMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayOverviewMapView.swift; sourceTree = "<group>"; };
557+
00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClusteringFeatureReductionToAPointFeatureLayerView.swift; sourceTree = "<group>"; };
558+
00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift; sourceTree = "<group>"; };
559+
00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift; sourceTree = "<group>"; };
548560
00C7993A2A845AAF00AFE342 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
549561
00C94A0C28B53DE1004E42D9 /* raster-file */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "raster-file"; sourceTree = "<group>"; };
550562
00CB9137284814A4005C2C5D /* SearchWithGeocodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchWithGeocodeView.swift; sourceTree = "<group>"; };
@@ -799,6 +811,7 @@
799811
0074ABB228174B830037244A /* Samples */ = {
800812
isa = PBXGroup;
801813
children = (
814+
00B56F752B0E966600B68A0D /* Add clustering feature reduction to a point feature layer */,
802815
79D84D0C2A815BED00F45262 /* Add custom dynamic entity data source */,
803816
4D2ADC3E29C26D05003B367F /* Add dynamic entity layer */,
804817
00D4EF7E2863840D00B9CC30 /* Add feature layers */,
@@ -962,6 +975,16 @@
962975
path = "Display overview map";
963976
sourceTree = "<group>";
964977
};
978+
00B56F752B0E966600B68A0D /* Add clustering feature reduction to a point feature layer */ = {
979+
isa = PBXGroup;
980+
children = (
981+
00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */,
982+
00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */,
983+
00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */,
984+
);
985+
path = "Add clustering feature reduction to a point feature layer";
986+
sourceTree = "<group>";
987+
};
965988
00C94A0228B53DCC004E42D9 /* 7c4c679ab06a4df19dc497f577f111bd */ = {
966989
isa = PBXGroup;
967990
children = (
@@ -2230,6 +2253,7 @@
22302253
D752D9402A39154C003EB25E /* ManageOperationalLayersView.swift in Sources */,
22312254
D7ABA2F92A32579C0021822B /* MeasureDistanceInSceneView.swift in Sources */,
22322255
D73723792AF5ADD800846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift in Sources */,
2256+
00B56F792B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Sources */,
22332257
E004A6E028466279002A1FE6 /* ShowCalloutView.swift in Sources */,
22342258
E000E763286A0B18005D87C5 /* CutGeometryView.swift in Sources */,
22352259
D7705D582AFC244E00CC0335 /* FindClosestFacilityToMultiplePointsView.swift in Sources */,
@@ -2295,10 +2319,12 @@
22952319
1CAF831F2A20305F000E1E60 /* ShowUtilityAssociationsView.swift in Sources */,
22962320
00C7993B2A845AAF00AFE342 /* Sidebar.swift in Sources */,
22972321
E004A6C128414332002A1FE6 /* SetViewpointRotationView.swift in Sources */,
2322+
00B56F7B2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Sources */,
22982323
883C121529C9136600062FF9 /* DownloadPreplannedMapAreaView.MapPicker.swift in Sources */,
22992324
D72C43F32AEB066D00B6157B /* GeocodeOfflineView.Model.swift in Sources */,
23002325
1C9B74C929DB43580038B06F /* ShowRealisticLightAndShadowsView.swift in Sources */,
23012326
D7232EE12AC1E5AA0079ABFF /* PlayKMLTourView.swift in Sources */,
2327+
00B56F7D2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Sources */,
23022328
D7010EBF2B05616900D43F55 /* DisplaySceneFromMobileScenePackageView.swift in Sources */,
23032329
1C56B5E62A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift in Sources */,
23042330
D7337C602ABD142D00A5D865 /* ShowMobileMapPackageExpirationDateView.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright 2023 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import ArcGIS
16+
import UIKit.UIColor
17+
18+
extension AddClusteringFeatureReductionToAPointFeatureLayerView {
19+
/// The model used to store the geo model and other expensive objects
20+
/// used in this view.
21+
class Model: ObservableObject {
22+
/// A Zurich buildings web map.
23+
let map = Map(
24+
item: PortalItem(
25+
portal: .arcGISOnline(connection: .anonymous),
26+
id: .zurichBuildings
27+
)
28+
)
29+
30+
/// A custom feature reduction for dynamically aggregating and
31+
/// summarizing groups of features as the map scale changes.
32+
private let clusteringFeatureReduction = makeCustomFeatureReduction(renderer: makeClassBreaksRenderer())
33+
34+
/// The buildings feature layer in the web map.
35+
var featureLayer: FeatureLayer? {
36+
map.operationalLayers.first as? FeatureLayer
37+
}
38+
39+
/// A Boolean value indicating whether cluster labels are displayed.
40+
var showsLabels: Bool {
41+
didSet {
42+
clusteringFeatureReduction.showsLabels = showsLabels
43+
}
44+
}
45+
46+
/// The maximum scale of feature clusters.
47+
/// - Note: The default value for max scale is 0.
48+
var maxScale: Double {
49+
didSet {
50+
clusteringFeatureReduction.maxScale = maxScale
51+
}
52+
}
53+
54+
/// The radius of feature clusters.
55+
/// - Note: The default value for cluster radius is 60.
56+
/// Larger radius allows more features to be grouped into a cluster.
57+
var radius: Double {
58+
didSet {
59+
clusteringFeatureReduction.radius = radius
60+
}
61+
}
62+
63+
init() {
64+
// Set initial values for controls.
65+
showsLabels = clusteringFeatureReduction.showsLabels
66+
radius = clusteringFeatureReduction.radius
67+
maxScale = clusteringFeatureReduction.maxScale ?? .zero
68+
}
69+
70+
/// Loads the web map and set up the feature layer.
71+
func setup() async throws {
72+
try await map.load()
73+
featureLayer?.featureReduction = clusteringFeatureReduction
74+
}
75+
76+
/// Creates a class breaks renderer for the custom feature reduction
77+
/// - Returns: A `ClassBreaksRenderer` object.
78+
private static func makeClassBreaksRenderer() -> ClassBreaksRenderer {
79+
// For each feature cluster with a given average building height,
80+
// a color is assigned to each symbol.
81+
let colors = [
82+
(4, 251, 255),
83+
(44, 211, 255),
84+
(74, 181, 255),
85+
(120, 135, 255),
86+
(165, 90, 255),
87+
(194, 61, 255),
88+
(224, 31, 255),
89+
(254, 1, 255)
90+
].map {
91+
UIColor(red: $0.0 / 255, green: $0.1 / 255, blue: $0.2 / 255, alpha: 1)
92+
}
93+
94+
// Create a class break and a symbol to display the features
95+
// in each value range.
96+
// In this case, the average building height ranges from 0 to 7 stories.
97+
let classBreaks = zip([Int](0...7), colors).map { value, color in
98+
ClassBreak(
99+
description: "\(value) floor",
100+
label: String(value),
101+
minValue: Double(value),
102+
maxValue: Double(value) + 1,
103+
symbol: SimpleMarkerSymbol(color: color)
104+
)
105+
}
106+
107+
// Create a class breaks renderer to apply to the custom
108+
// feature reduction.
109+
// Define the field to use for the class breaks renderer.
110+
// Note that this field name must match the name of an
111+
// aggregate field contained in the clustering feature reduction's
112+
// aggregate fields property.
113+
let renderer = ClassBreaksRenderer(fieldName: "Average Building Height", classBreaks: classBreaks)
114+
115+
// Create a default symbol for features that do not fall within
116+
// any of the ranges defined by the class breaks.
117+
renderer.defaultSymbol = SimpleMarkerSymbol(color: .systemPink)
118+
119+
return renderer
120+
}
121+
122+
/// Creates a custom feature reduction for the sample.
123+
/// - Parameter renderer: A renderer for drawing clustered features.
124+
/// - Returns: A `ClusteringFeatureReduction` object.
125+
private static func makeCustomFeatureReduction(renderer: ClassBreaksRenderer) -> ClusteringFeatureReduction {
126+
// Create a new clustering feature reduction using the
127+
// class breaks renderer.
128+
let clusteringFeatureReduction = ClusteringFeatureReduction(renderer: renderer)
129+
130+
// Set the feature reduction's aggregate fields.
131+
// Note that the field names must match those in the feature layer.
132+
// The aggregate fields summarize values based on the defined
133+
// aggregate statistic type.
134+
clusteringFeatureReduction.addAggregateFields([
135+
AggregateField(
136+
name: "Total Residential Buildings",
137+
statisticFieldName: "Residential_Buildings",
138+
statisticType: .sum
139+
),
140+
AggregateField(
141+
name: "Average Building Height",
142+
statisticFieldName: "Most_common_number_of_storeys",
143+
statisticType: .mode
144+
)
145+
])
146+
147+
// Enable the feature reduction.
148+
clusteringFeatureReduction.isEnabled = true
149+
150+
// Set the popup definition for the custom feature reduction.
151+
clusteringFeatureReduction.popupDefinition = PopupDefinition(popupSource: clusteringFeatureReduction)
152+
153+
// Set values for the feature reduction's cluster minimum
154+
// and maximum symbol sizes. Note that the default values
155+
// for max and min symbol size are 70 and 12 respectively.
156+
clusteringFeatureReduction.minSymbolSize = 5
157+
clusteringFeatureReduction.maxSymbolSize = 90
158+
159+
// Create a label definition with a simple label expression.
160+
let labelDefinition = LabelDefinition(
161+
labelExpression: SimpleLabelExpression(simpleExpression: "[cluster_count]"),
162+
textSymbol: TextSymbol(color: .black, size: 15)
163+
)
164+
labelDefinition.placement = .pointCenterCenter
165+
166+
// Add the label definition to the feature reduction.
167+
clusteringFeatureReduction.addLabelDefinition(labelDefinition)
168+
169+
return clusteringFeatureReduction
170+
}
171+
}
172+
}
173+
174+
private extension PortalItem.ID {
175+
/// The ID used in the Zurich buildings web map.
176+
static var zurichBuildings: Self { Self("aa44e79a4836413c89908e1afdace2ea")! }
177+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2023 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import SwiftUI
16+
17+
extension AddClusteringFeatureReductionToAPointFeatureLayerView {
18+
struct SettingsView: View {
19+
/// The model for the sample.
20+
@ObservedObject var model: Model
21+
22+
/// The action to dismiss the settings sheet.
23+
@Environment(\.dismiss) private var dismiss: DismissAction
24+
25+
/// The map view's scale.
26+
let mapViewScale: Double
27+
28+
/// A format style to display a floating point number's integer part.
29+
private let formatStyle: FloatingPointFormatStyle<Double> = .number.precision(.fractionLength(0))
30+
31+
/// The maximum scale of feature clusters.
32+
@State private var maxScale = 0.0
33+
34+
/// The radius of feature clusters.
35+
@State private var radius = 60.0
36+
37+
var body: some View {
38+
Form {
39+
Section("Cluster Labels Visibility") {
40+
Toggle("Show Labels", isOn: $model.showsLabels)
41+
.toggleStyle(.switch)
42+
}
43+
44+
Section("Clustering Properties") {
45+
VStack {
46+
HStack {
47+
Text("Cluster Radius")
48+
Spacer()
49+
Text(radius, format: formatStyle)
50+
.foregroundColor(.secondary)
51+
}
52+
Slider(
53+
value: $radius,
54+
in: 30...85,
55+
onEditingChanged: { isEditing in
56+
if !isEditing {
57+
model.radius = radius
58+
}
59+
}
60+
)
61+
}
62+
VStack {
63+
HStack {
64+
Text("Cluster Max Scale")
65+
Spacer()
66+
Text(maxScale, format: formatStyle)
67+
.foregroundColor(.secondary)
68+
}
69+
Slider(
70+
value: $maxScale,
71+
in: 0...150000,
72+
onEditingChanged: { isEditing in
73+
if !isEditing {
74+
model.maxScale = maxScale
75+
}
76+
}
77+
)
78+
}
79+
HStack {
80+
Text("Current Map Scale")
81+
Spacer()
82+
Text(mapViewScale, format: formatStyle)
83+
.foregroundColor(.secondary)
84+
}
85+
}
86+
}
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)