diff --git a/Samples.xcodeproj/project.pbxproj b/Samples.xcodeproj/project.pbxproj index fd1deb1ab..99b8dd718 100644 --- a/Samples.xcodeproj/project.pbxproj +++ b/Samples.xcodeproj/project.pbxproj @@ -304,6 +304,7 @@ 88FB70D92DD3DFA800EB76E3 /* AddOpenStreetMapLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 88FB70D72DD3DF3700EB76E3 /* AddOpenStreetMapLayerView.swift */; }; 9501BBEF2DF39DAB0054F4BD /* SetFeatureLayerRenderingModeOnSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9501BBEE2DF39DA50054F4BD /* SetFeatureLayerRenderingModeOnSceneView.swift */; }; 9503056E2C46ECB70091B32D /* ShowDeviceLocationUsingIndoorPositioningView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9503056D2C46ECB70091B32D /* ShowDeviceLocationUsingIndoorPositioningView.Model.swift */; }; + 951961AC2E00BC420088B0C2 /* ShowGeodesicSectorAndEllipseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951961AB2E00BC3C0088B0C2 /* ShowGeodesicSectorAndEllipseView.swift */; }; 951961AE2E00BD430088B0C2 /* SetMapImageLayerSublayerVisibilityView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 95813E382DF88FD000342CBF /* SetMapImageLayerSublayerVisibilityView.swift */; }; 9525404E2DF904BA004090B9 /* SetFeatureLayerRenderingModeOnSceneView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 9501BBEE2DF39DA50054F4BD /* SetFeatureLayerRenderingModeOnSceneView.swift */; }; 9529D1942C01676200B5C1A3 /* SelectFeaturesInSceneLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 954AEDED2C01332600265114 /* SelectFeaturesInSceneLayerView.swift */; }; @@ -330,6 +331,7 @@ 95F3A52B2C07F09C00885DED /* SetSurfaceNavigationConstraintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F3A52A2C07F09C00885DED /* SetSurfaceNavigationConstraintView.swift */; }; 95F3A52D2C07F28700885DED /* SetSurfaceNavigationConstraintView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 95F3A52A2C07F09C00885DED /* SetSurfaceNavigationConstraintView.swift */; }; 95F891292C46E9D60010EBED /* ShowDeviceLocationUsingIndoorPositioningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F891282C46E9D60010EBED /* ShowDeviceLocationUsingIndoorPositioningView.swift */; }; + 95FFEB442E06211B00543993 /* ShowGeodesicSectorAndEllipseView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 951961AB2E00BC3C0088B0C2 /* ShowGeodesicSectorAndEllipseView.swift */; }; D70082EB2ACF900100E0C3C2 /* IdentifyKMLFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70082EA2ACF900100E0C3C2 /* IdentifyKMLFeaturesView.swift */; }; D70082EC2ACF901600E0C3C2 /* IdentifyKMLFeaturesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D70082EA2ACF900100E0C3C2 /* IdentifyKMLFeaturesView.swift */; }; D7010EBF2B05616900D43F55 /* DisplaySceneFromMobileScenePackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7010EBC2B05616900D43F55 /* DisplaySceneFromMobileScenePackageView.swift */; }; @@ -721,6 +723,7 @@ dstPath = ""; dstSubfolderSpec = 7; files = ( + 95FFEB442E06211B00543993 /* ShowGeodesicSectorAndEllipseView.swift in Copy Source Code Files */, 88FB70D92DD3DFA800EB76E3 /* AddOpenStreetMapLayerView.swift in Copy Source Code Files */, 951961AE2E00BD430088B0C2 /* SetMapImageLayerSublayerVisibilityView.swift in Copy Source Code Files */, 9525404E2DF904BA004090B9 /* SetFeatureLayerRenderingModeOnSceneView.swift in Copy Source Code Files */, @@ -1179,6 +1182,7 @@ 88FB70D72DD3DF3700EB76E3 /* AddOpenStreetMapLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddOpenStreetMapLayerView.swift; sourceTree = ""; }; 9501BBEE2DF39DA50054F4BD /* SetFeatureLayerRenderingModeOnSceneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetFeatureLayerRenderingModeOnSceneView.swift; sourceTree = ""; }; 9503056D2C46ECB70091B32D /* ShowDeviceLocationUsingIndoorPositioningView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowDeviceLocationUsingIndoorPositioningView.Model.swift; sourceTree = ""; }; + 951961AB2E00BC3C0088B0C2 /* ShowGeodesicSectorAndEllipseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowGeodesicSectorAndEllipseView.swift; sourceTree = ""; }; 9537AFD62C220EF0000923C5 /* ExchangeSetwithoutUpdates */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ExchangeSetwithoutUpdates; sourceTree = ""; }; 9547085B2C3C719800CA8579 /* EditFeatureAttachmentsView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFeatureAttachmentsView.Model.swift; sourceTree = ""; }; 954AEDED2C01332600265114 /* SelectFeaturesInSceneLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectFeaturesInSceneLayerView.swift; sourceTree = ""; }; @@ -1771,6 +1775,7 @@ D722BD1E2A420D7E002C2087 /* Show extruded features */, 1C7B861B2DFB4EAF00B267EA /* Show extruded graphics */, 88129D782DD5034B001599A5 /* Show geodesic path between two points */, + 951961AD2E00BC760088B0C2 /* Show geodesic sector and ellipse */, 00ABA94B2BF671FC00C0488C /* Show grid */, D751017D2A2E490800B8FA48 /* Show labels on layer */, 1C293D562DD53523000B0822 /* Show labels on layer in 3D */, @@ -2682,6 +2687,14 @@ path = "Set feature layer rendering mode on scene"; sourceTree = ""; }; + 951961AD2E00BC760088B0C2 /* Show geodesic sector and ellipse */ = { + isa = PBXGroup; + children = ( + 951961AB2E00BC3C0088B0C2 /* ShowGeodesicSectorAndEllipseView.swift */, + ); + path = "Show geodesic sector and ellipse"; + sourceTree = ""; + }; 9537AFC82C220ECB000923C5 /* 9d2987a825c646468b3ce7512fb76e2d */ = { isa = PBXGroup; children = ( @@ -4376,6 +4389,7 @@ D77BC5392B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift in Sources */, D7084FA92AD771AA00EC7F4F /* AugmentRealityToFlyOverSceneView.swift in Sources */, D75B58512AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift in Sources */, + 951961AC2E00BC420088B0C2 /* ShowGeodesicSectorAndEllipseView.swift in Sources */, 0072C8002DBAF08D001502CA /* AddItemsToPortalView.swift in Sources */, E0D04FF228A5390000747989 /* DownloadPreplannedMapAreaView.Model.swift in Sources */, 88129D7A2DD5035A001599A5 /* ShowGeodesicPathBetweenTwoPointsView.swift in Sources */, diff --git a/Shared/Samples/Show geodesic sector and ellipse/README.md b/Shared/Samples/Show geodesic sector and ellipse/README.md new file mode 100644 index 000000000..0d042e2c9 --- /dev/null +++ b/Shared/Samples/Show geodesic sector and ellipse/README.md @@ -0,0 +1,39 @@ +# Show geodesic sector and ellipse + +Create and display geodesic sectors and ellipses. + +![Image of show geodesic sector and ellipse](show-geodesic-sector-and-ellipse.png) + +## Use case + +Geodesic sectors and ellipses can be used in a wide range of analyses ranging from projectile landing zones to antenna coverage. For example, given the strength and direction of a cellular tower's signal, you could generate cell coverage geometries to identify areas without sufficient connectivity. + +## How to use the sample + +The geodesic sector and ellipse will display with default parameters at the start. Click anywhere on the map to change the center of the geometries. Adjust any of the controls to see how they affect the sector and ellipse on the fly. + +## How it works + +To create a geodesic sector and ellipse: + +1. Create GeodesicSectorParameters and GeodesicEllipseParameters using one of the constructors with default values or using each setter individually. +2. Set the center, axisDirection, semiAxis1Length, and the semiAxis2Length properties to change the general ellipse position, shape, and orientation. +3. Set the sectorAngle and startDirection angles to change the sector's shape and orientation. +4. Set the maxPointCount and maxSegmentLength properties to control the complexity of the geometries and the approximation of the ellipse curve. +5. Specify the geometryType to either POLYGON, POLYLINE, or MULTIPOINT to change the result geometry type. +6. Pass the parameters to the related static methods: GeometryEngine.ellipseGeodesic(geodesicEllipseParameters) and GeometryEngine.sectorGeodesic(geodesicSectorParameters). The returned value will be a Geometry of the type specified by the geometryType parameter. + +## Relevant API + +GeodesicEllipseParameters +GeodesicSectorParameters +GeometryEngine +GeometryType + +## Additional information + +To create a circle instead of an ellipse, simply set semiAxis2Length to 0.0 and semiAxis1Length to the desired radius of the circle. This eliminates the need to update both parameters to the same value. + +## Tags + +ellipse, geodesic, geometry, sector diff --git a/Shared/Samples/Show geodesic sector and ellipse/README.metadata.json b/Shared/Samples/Show geodesic sector and ellipse/README.metadata.json new file mode 100644 index 000000000..7ad83ac1a --- /dev/null +++ b/Shared/Samples/Show geodesic sector and ellipse/README.metadata.json @@ -0,0 +1,29 @@ +{ + "category": "Visualization", + "description": "Create and display geodesic sectors and ellipses.", + "ignore": false, + "images": [ + "show-geodesic-sector-and-ellipse.png" + ], + "keywords": [ + "ellipse", + "geodesic", + "geometry", + "sector", + "GeodesicEllipseParameters", + "GeodesicSectorParameters", + "GeometryEngine", + "GeometryType" + ], + "redirect_from": [], + "relevant_apis": [ + "GeodesicEllipseParameters", + "GeodesicSectorParameters", + "GeometryEngine", + "GeometryType" + ], + "snippets": [ + "ShowGeodesicSectorAndEllipseView.swift" + ], + "title": "Show geodesic sector and ellipse" +} diff --git a/Shared/Samples/Show geodesic sector and ellipse/ShowGeodesicSectorAndEllipseView.swift b/Shared/Samples/Show geodesic sector and ellipse/ShowGeodesicSectorAndEllipseView.swift new file mode 100644 index 000000000..118f52aad --- /dev/null +++ b/Shared/Samples/Show geodesic sector and ellipse/ShowGeodesicSectorAndEllipseView.swift @@ -0,0 +1,284 @@ +// Copyright 2025 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 ArcGIS +import SwiftUI + +struct ShowGeodesicSectorAndEllipseView: View { + /// The data model that helps determine the view. It is an objerved object. + @StateObject private var model = Model() + + /// The map point selected by the user when tapping on the map. + @State private var tapPoint: Point? + + /// Manages the presentation state of the menu. + @State private var isPresented: Bool = false + + var body: some View { + MapViewReader { proxy in + MapView( + map: model.map, + graphicsOverlays: [ + model.ellipseGraphicOverlay, + model.sectorGraphicOverlay + ] + ) + .onSingleTapGesture { _, tapPoint in + self.tapPoint = tapPoint + } + .task(id: tapPoint) { + if let tapPoint { + await proxy.setViewpoint( + Viewpoint( + center: tapPoint, scale: 1e7 + ) + ) + model.updateSector(tapPoint: tapPoint) + } + } + .toolbar { + // The menu which holds the options that change the ellipse and sector. + ToolbarItemGroup(placement: .bottomBar) { + Button("Geodesic Sector & Ellipse Settings") { + isPresented.toggle() + } + .popover(isPresented: $isPresented) { + Form { + Section { + ParameterSlider(label: "Axis Direction:", value: $model.axisDirection, range: 0...360, tapPoint: tapPoint) { + model.updateSector(tapPoint: tapPoint) + } + Stepper(value: $model.maxPointCount, in: 0...1000, step: 1) { + Text("Max Point Count: \(String(format: "%d", model.maxPointCount))") + } + .font(.caption) + .onChange(of: model.maxPointCount) { + model.updateSector(tapPoint: tapPoint) + } + ParameterSlider( + label: "Max Segment Length:", value: $model.maxSegmentLength, range: 1...1000, tapPoint: tapPoint) { + model.updateSector(tapPoint: tapPoint) + } + GeometryTypeMenu( + selected: $model.selectedGeometryType + ) + .onChange(of: model.selectedGeometryType) { + model.updateSector(tapPoint: tapPoint) + } + ParameterSlider(label: "Sector Angle:", value: $model.sectorAngle, range: 0...360, tapPoint: tapPoint) { + model.updateSector(tapPoint: tapPoint) + } + ParameterSlider(label: "Semi Axis 1 Length:", value: $model.semiAxis1Length, range: 0...1000, tapPoint: tapPoint) { + model.updateSector(tapPoint: tapPoint) + } + ParameterSlider(label: "Semi Axis 2 Length:", value: $model.semiAxis2Length, range: 0...1000, tapPoint: tapPoint) { + model.updateSector(tapPoint: tapPoint) + } + } + } + .presentationDetents([.medium]) + } + } + } + } + } +} + +private extension ShowGeodesicSectorAndEllipseView { + /// Custom data type so that Geometry options can be displayed in the menu. + enum GeometryType: CaseIterable { + case point, polyline, polygon + + var label: String { + switch self { + case .point: "Point" + case .polyline: "Polyline" + case .polygon: "Polygon" + } + } + } + + /// A view model that encapsulates logic and state for rendering a geodesic sector and ellipse. + /// Handles user-configured parameters and updates overlays when those parameters change. + @MainActor + class Model: ObservableObject { + /// The map that will be displayed in the map view. + var map = Map(basemapStyle: .arcGISTopographic) + + /// The graphics overlay that will be displayed on the map view. + /// This will hold the graphics that show the ellipse path. + var ellipseGraphicOverlay = GraphicsOverlay() + + /// The graphics overlay that will be displayed on the map view. + /// This will display a highlighted section of the ellipse path. + var sectorGraphicOverlay = GraphicsOverlay() + + private let sectorLineSymbol = SimpleLineSymbol(style: .solid, color: .green, width: 2) + private let sectorMarkerSymbol = SimpleMarkerSymbol(style: .circle, color: .green, size: 2) + private let ellipseLineSymbol = SimpleLineSymbol(style: .dash, color: .red, width: 2) + private let sectorFillSymbol = SimpleFillSymbol(style: .solid, color: .green) + + private var ellipseGraphic: Graphic! + private var sectorGraphic: Graphic! + + /// The direction (in degrees) of the ellipse's major axis. + @Published var axisDirection: Double = 45 + @Published var maxSegmentLength: Double = 1 + @Published var sectorAngle: Double = 90 + @Published var maxPointCount: Int = 2000 + @Published var semiAxis1Length: Double = 200 + @Published var semiAxis2Length: Double = 100 + @Published var selectedGeometryType: GeometryType = .polygon + @Published var startDirection: Double = 45 + + func updateSector(tapPoint: Point?) { + guard let tapPoint = tapPoint else { return } + ellipseGraphicOverlay.removeAllGraphics() + sectorGraphicOverlay.removeAllGraphics() + setupSector(tapPoint: tapPoint, geometryType: selectedGeometryType) + updateEllipse(tapPoint: tapPoint) + } + + private func setupSector(tapPoint: Point, geometryType: GeometryType) { + switch geometryType { + case .point: + // Generate sector as a multipoint (symbols) + var params = GeodesicSectorParameters() + fillSectorParams(¶ms, center: tapPoint) + let geometry = GeometryEngine.geodesicSector(parameters: params) + addSectorGraphic(geometry: geometry, symbol: sectorMarkerSymbol) + case .polyline: + // Generate sector as a polyline (outlined arc) + var params = GeodesicSectorParameters() + fillSectorParams(¶ms, center: tapPoint) + let geometry = GeometryEngine.geodesicSector(parameters: params) + addSectorGraphic(geometry: geometry, symbol: sectorLineSymbol) + case .polygon: + // Generate sector as a filled polygon + var params = GeodesicSectorParameters() + fillSectorParams(¶ms, center: tapPoint) + let geometry = GeometryEngine.geodesicSector(parameters: params) + addSectorGraphic(geometry: geometry, symbol: sectorFillSymbol) + } + } + + /// Populates a `GeodesicSectorParameters` instance with current user-defined values. + /// - Parameter params: A reference to the parameter struct that will be filled. + /// - Parameter center: The center point for the sector/ellipse. + private func fillSectorParams(_ params: inout GeodesicSectorParameters, center: Point) { + params.center = center + params.axisDirection = axisDirection + params.maxPointCount = maxPointCount + params.maxSegmentLength = maxSegmentLength + params.sectorAngle = sectorAngle + params.semiAxis1Length = semiAxis1Length + params.semiAxis2Length = semiAxis2Length + params.startDirection = startDirection + params.linearUnit = .miles + } + + /// Adds a sector graphic to the overlay and applies the appropriate renderer. + private func addSectorGraphic(geometry: Geometry?, symbol: Symbol) { + guard let geometry = geometry else { return } + sectorGraphic = Graphic(geometry: geometry, symbol: symbol) + sectorGraphicOverlay.renderer = SimpleRenderer(symbol: symbol) + sectorGraphicOverlay.addGraphic(sectorGraphic) + } + + /// Generates and adds a geodesic ellipse graphic based on the current settings and tap point. + private func updateEllipse(tapPoint: Point?) { + guard let tapPoint = tapPoint else { + return + } + let parameters = GeodesicEllipseParameters( + axisDirection: axisDirection, + center: tapPoint, + linearUnit: .miles, + maxPointCount: maxPointCount, + maxSegmentLength: maxSegmentLength, + semiAxis1Length: semiAxis1Length, + semiAxis2Length: semiAxis2Length + ) + let ellipseGeometry = GeometryEngine.geodesicEllipse(parameters: parameters) + ellipseGraphic = Graphic(geometry: ellipseGeometry, symbol: ellipseLineSymbol) + ellipseGraphicOverlay.addGraphic(ellipseGraphic) + } + } + + /// A reusable UI component for adjusting a numeric parameter using a slider. + /// Updates the sector/ellipse dynamically when changed. + struct ParameterSlider: View { + let label: String + @Binding var value: Double + let range: ClosedRange + var tapPoint: Point? + let onUpdate: () -> Void + + var body: some View { + Slider(value: $value, in: range) { + } minimumValueLabel: { + Text(label) + .font(.caption) + } maximumValueLabel: { + Text("\(String(format: "%.0f", value))") + .font(.caption) + } + .onChange(of: value) { + onUpdate() + } + } + } + + /// A menu component that allows the user to choose between point, polyline, or polygon geometry types for the sector. + struct GeometryTypeMenu: View { + @Binding var selected: GeometryType + + var body: some View { + Menu { + ForEach(GeometryType.allCases, id: \.self) { mode in + Button { + selected = mode + } label: { + Text(mode.label) + .font(.caption) + .foregroundColor(.black) + } + } + } label: { + HStack { + Text("Geometry Type: ") + .foregroundColor(.black) + Spacer() + Text(selected.label) + .fontWeight(.bold) + .foregroundColor(.gray) + VStack { + Image(systemName: "chevron.up") + .font(.caption2) + .fontWeight(.medium) + Image(systemName: "chevron.down") + .font(.caption2) + .fontWeight(.medium) + } + .foregroundColor(.gray) + } + .font(.caption) + } + } + } +} + +#Preview { + ShowGeodesicSectorAndEllipseView() +} diff --git a/Shared/Samples/Show geodesic sector and ellipse/show-geodesic-sector-and-ellipse.png b/Shared/Samples/Show geodesic sector and ellipse/show-geodesic-sector-and-ellipse.png new file mode 100644 index 000000000..bcc7141b2 Binary files /dev/null and b/Shared/Samples/Show geodesic sector and ellipse/show-geodesic-sector-and-ellipse.png differ