diff --git a/apps/ios/GuideDogs.xcodeproj/project.pbxproj b/apps/ios/GuideDogs.xcodeproj/project.pbxproj index bddb29a6..8acdc9d0 100644 --- a/apps/ios/GuideDogs.xcodeproj/project.pbxproj +++ b/apps/ios/GuideDogs.xcodeproj/project.pbxproj @@ -666,13 +666,9 @@ 914BAAF32AD745E400CB2171 /* DestinationManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAAF22AD745E400CB2171 /* DestinationManagerTest.swift */; }; 914BAAFD2AD7483300CB2171 /* AudioEngineTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAAFC2AD7483300CB2171 /* AudioEngineTest.swift */; }; 915FF9F62ADE4BAF002B3690 /* AuthoredActivityContentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915FF9F42ADE3F91002B3690 /* AuthoredActivityContentTest.swift */; }; - 91745DD52AFB0E6C003EC104 /* GeoJsonGeometryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91745DD42AFB0E6C003EC104 /* GeoJsonGeometryTest.swift */; }; - 91745DD62AFB0F32003EC104 /* GeometryUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914DEBDC2A3CE901007B161C /* GeometryUtilsTest.swift */; }; - 91745DD82AFC48E0003EC104 /* GeoJsonFeatureTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91745DD72AFC48E0003EC104 /* GeoJsonFeatureTest.swift */; }; - 91745DDA2AFED7FF003EC104 /* GeoJsonFeatureCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91745DD92AFED7FF003EC104 /* GeoJsonFeatureCollection.swift */; }; - 91B6ADAA2AF19CB600FFE4E9 /* OSMServiceModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B6ADA92AF19CB600FFE4E9 /* OSMServiceModelTest.swift */; }; 91C82AAD2A5DCF040086D126 /* GeolocationManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91C82AAC2A5DCF040086D126 /* GeolocationManagerTest.swift */; }; 91C82ABE2A6B08500086D126 /* RouteGuidanceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91C82ABD2A6B08500086D126 /* RouteGuidanceTest.swift */; }; + 91DC0CF92A46134600244CC8 /* GeometryUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914DEBDC2A3CE901007B161C /* GeometryUtilsTest.swift */; }; B90C27D61EAF81D600007368 /* Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90C27D51EAF81D600007368 /* Sound.swift */; }; B918EE9825100FFF00A5354A /* CalloutRangeContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B918EE9725100FFF00A5354A /* CalloutRangeContext.swift */; }; B91D3F6427AB5546004159A8 /* UserAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B91D3F6327AB5546004159A8 /* UserAction.swift */; }; @@ -1588,10 +1584,6 @@ 914DEBCD2A3CE6B9007B161C /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 914DEBDC2A3CE901007B161C /* GeometryUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryUtilsTest.swift; sourceTree = ""; }; 915FF9F42ADE3F91002B3690 /* AuthoredActivityContentTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthoredActivityContentTest.swift; sourceTree = ""; }; - 91745DD42AFB0E6C003EC104 /* GeoJsonGeometryTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoJsonGeometryTest.swift; sourceTree = ""; }; - 91745DD72AFC48E0003EC104 /* GeoJsonFeatureTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJsonFeatureTest.swift; sourceTree = ""; }; - 91745DD92AFED7FF003EC104 /* GeoJsonFeatureCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJsonFeatureCollection.swift; sourceTree = ""; }; - 91B6ADA92AF19CB600FFE4E9 /* OSMServiceModelTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSMServiceModelTest.swift; sourceTree = ""; }; 91C82AAC2A5DCF040086D126 /* GeolocationManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = " GeolocationManagerTest.swift"; path = "UnitTests/Sensors/Geolocation/Geolocation Manager/ GeolocationManagerTest.swift"; sourceTree = SOURCE_ROOT; }; 91C82ABD2A6B08500086D126 /* RouteGuidanceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RouteGuidanceTest.swift; path = "UnitTests/Behaviors/Route Guidance/RouteGuidanceTest.swift"; sourceTree = SOURCE_ROOT; }; B90C27D51EAF81D600007368 /* Sound.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Sound.swift; path = Code/Audio/Protocols/Sound.swift; sourceTree = ""; }; @@ -4285,22 +4277,6 @@ path = "Interactive View"; sourceTree = ""; }; - 914BAAED2AD745BC00CB2171 /* Services */ = { - isa = PBXGroup; - children = ( - 914BAAEE2AD745BC00CB2171 /* OSM */, - ); - path = Services; - sourceTree = ""; - }; - 914BAAEE2AD745BC00CB2171 /* OSM */ = { - isa = PBXGroup; - children = ( - 91B6ADA92AF19CB600FFE4E9 /* OSMServiceModelTest.swift */, - ); - path = OSM; - sourceTree = ""; - }; 914BAAF12AD745E400CB2171 /* Destination Manager */ = { isa = PBXGroup; children = ( @@ -4353,31 +4329,6 @@ path = "Authored Activities"; sourceTree = ""; }; - 91745DD12AFB0E6C003EC104 /* Models */ = { - isa = PBXGroup; - children = ( - 91745DD22AFB0E6C003EC104 /* JSON Parsing */, - ); - path = Models; - sourceTree = ""; - }; - 91745DD22AFB0E6C003EC104 /* JSON Parsing */ = { - isa = PBXGroup; - children = ( - 91745DD32AFB0E6C003EC104 /* OSM */, - ); - path = "JSON Parsing"; - sourceTree = ""; - }; - 91745DD32AFB0E6C003EC104 /* OSM */ = { - isa = PBXGroup; - children = ( - 91745DD72AFC48E0003EC104 /* GeoJsonFeatureTest.swift */, - 91745DD42AFB0E6C003EC104 /* GeoJsonGeometryTest.swift */, - ); - path = OSM; - sourceTree = ""; - }; 91C82AA62A4F56A70086D126 /* Sensors */ = { isa = PBXGroup; children = ( @@ -4405,10 +4356,8 @@ 91C82AB52A67182E0086D126 /* Data */ = { isa = PBXGroup; children = ( - 91745DD12AFB0E6C003EC104 /* Models */, 915FF9F32ADE3F91002B3690 /* Authored Activities */, 914BAAF12AD745E400CB2171 /* Destination Manager */, - 914BAAED2AD745BC00CB2171 /* Services */, ); path = Data; sourceTree = ""; @@ -4518,7 +4467,6 @@ children = ( D298328F1E4BF80600352A5A /* GeoJsonFeature.swift */, D29832951E4E249700352A5A /* GeoJsonGeometry.swift */, - 91745DD92AFED7FF003EC104 /* GeoJsonFeatureCollection.swift */, ); name = OSM; sourceTree = ""; @@ -5594,10 +5542,7 @@ 914BAAF32AD745E400CB2171 /* DestinationManagerTest.swift in Sources */, 91C82ABE2A6B08500086D126 /* RouteGuidanceTest.swift in Sources */, 91C82AAD2A5DCF040086D126 /* GeolocationManagerTest.swift in Sources */, - 91745DD82AFC48E0003EC104 /* GeoJsonFeatureTest.swift in Sources */, - 91745DD52AFB0E6C003EC104 /* GeoJsonGeometryTest.swift in Sources */, - 91B6ADAA2AF19CB600FFE4E9 /* OSMServiceModelTest.swift in Sources */, - 91745DD62AFB0F32003EC104 /* GeometryUtilsTest.swift in Sources */, + 91DC0CF92A46134600244CC8 /* GeometryUtilsTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6107,7 +6052,6 @@ 31D0D7301E031A0E0087C847 /* UINavigationItem+Extension.swift in Sources */, 2821F61B220A6D1600D15EFF /* AuthoredActivityLoader.swift in Sources */, 6220D2D027CD7D4B0063BEA6 /* BeaconPickerItemView.swift in Sources */, - 91745DDA2AFED7FF003EC104 /* GeoJsonFeatureCollection.swift in Sources */, 287D3E8D22DE50340084B92B /* StatusTableViewController.swift in Sources */, 6258B3872469DAFA0051F60B /* UniversalLinkPathComponents.swift in Sources */, 28A16122283C3AF00081CFA4 /* TourGenerator.swift in Sources */, diff --git a/apps/ios/GuideDogs/Code/App/ExternalNavigationApps.swift b/apps/ios/GuideDogs/Code/App/ExternalNavigationApps.swift index 71d64f5d..e81a3fab 100644 --- a/apps/ios/GuideDogs/Code/App/ExternalNavigationApps.swift +++ b/apps/ios/GuideDogs/Code/App/ExternalNavigationApps.swift @@ -14,7 +14,7 @@ import CoreLocation /// then add to the enum and the switches below /// "deeplinks" are different than URL schemes; deeplinks can be arbitrary domains /// and will usually fall back to a web version if the corresponding app isn't installed, while URL schemes tend to be less cross-platform and need to be manually added in Info.plist. -enum ExternalNavigationApps: String, CaseIterable { +enum ExternalNavigationApps: String, CaseIterable{ case appleMaps case googleMaps case waze @@ -22,7 +22,7 @@ enum ExternalNavigationApps: String, CaseIterable { /// Should return a localized title for each supported app var localizedTitle: String { switch self { - case .googleMaps: return "Google Maps" + case .googleMaps: return "Google Maps" case .waze: return "Waze" case .appleMaps: return "Apple Maps" } diff --git a/apps/ios/GuideDogs/Code/App/Helpers/GeometryUtils.swift b/apps/ios/GuideDogs/Code/App/Helpers/GeometryUtils.swift index 20e49b9d..c4ed974a 100644 --- a/apps/ios/GuideDogs/Code/App/Helpers/GeometryUtils.swift +++ b/apps/ios/GuideDogs/Code/App/Helpers/GeometryUtils.swift @@ -27,6 +27,30 @@ class GeometryUtils { static let earthRadius = Double(6378137) + /// Parses a GeoJSON string and returns the coordinates and type values. + /// + /// See: + /// * https://geojson.org + /// * RFC 7946 + static func coordinates(geoJson: String) -> (type: GeometryType?, points: [Any]?) { + guard !geoJson.isEmpty, let jsonObject = GDAJSONObject(string: geoJson) else { + return (nil, nil) + } + + let geometryType: GeometryType? + if let typeString = jsonObject.string(atPath: "type") { + geometryType = GeometryType(rawValue: typeString) + } else { + geometryType = nil + } + + guard let geometry = jsonObject.array(atPath: "coordinates") else { + return (geometryType, nil) + } + + return (geometryType, geometry) + } + /// Returns whether a coordinate lies inside of the region contained within the coordinate path. /// The path is always considered closed, regardless of whether the last point equals the first or not. static func geometryContainsLocation(location: CLLocationCoordinate2D, coordinates: [CLLocationCoordinate2D]) -> Bool { @@ -283,9 +307,24 @@ class GeometryUtils { return ((cX - projX) * (cX - projX) + (cY - projY) * (cY - projY), lat, lon) } + /// Finds the closest point on an edge of the polygon (including intermediate points along edges) to the given coordinate. + /// - Returns: `nil` if the polygon is empty (i.e. no edges) + static func closestEdge(from coordinate: CLLocationCoordinate2D, on polygon: GAMultiLine) -> CLLocation? { + var coordinates: [CLLocationCoordinate2D] = [] + + // Transform to a continuous coordinates path + for line in polygon { + for point in line { + coordinates.append(point.toCoordinate()) + } + } + + return closestEdge(from: coordinate, on: coordinates) + } + /// Finds the closest point on the path (including intermediate points along edges) to the given coordinate. /// - Returns: `nil` if there are no edges (less than two points in `path`) - static func closestEdge(from coordinate: CLLocationCoordinate2D, on path: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D? { + static func closestEdge(from coordinate: CLLocationCoordinate2D, on path: [CLLocationCoordinate2D]) -> CLLocation? { guard !path.isEmpty else { return nil; } @@ -294,7 +333,7 @@ class GeometryUtils { let zoomLevel: UInt = 23 let res: Double = VectorTile.groundResolution(latitude: coordinate.latitude, zoom: zoomLevel) - var closestLocation: CLLocationCoordinate2D? + var closestLocation: CLLocation? var minimumDistance = CLLocationDistanceMax for i in 0.. CLLocationCoordinate2D?` + static func centroid(geoJson: String) -> CLLocationCoordinate2D? { + guard let points = GeometryUtils.coordinates(geoJson: geoJson).points else { + return nil + } + + // Check if `points` contains one point (e.g. point) + if let point = points as? GAPoint { + return point.toCoordinate() + } + + // Check if `points` contains an array of points (e.g. line, polygon) + if let points = points as? GALine { + return GeometryUtils.centroid(coordinates: points.toCoordinates()) + } + + // Check if `points` contains a two dimensional array of points (e.g. lines, polygons) + if let points = points as? GAMultiLine { + let flattened = Array(points.toCoordinates().joined()) + return GeometryUtils.centroid(coordinates: flattened) + } + + return nil + } + /// Returns a generated coordinate representing the mean center of a given array of `CLLocation` objects. /// - Note: See `centroid(coordinates: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D?` static func centroid(locations: [CLLocation]) -> CLLocationCoordinate2D? { diff --git a/apps/ios/GuideDogs/Code/Data/Models/Cache Models/GDASpatialDataResultEntity.swift b/apps/ios/GuideDogs/Code/Data/Models/Cache Models/GDASpatialDataResultEntity.swift index 84f91dca..fa6e9f01 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/Cache Models/GDASpatialDataResultEntity.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/Cache Models/GDASpatialDataResultEntity.swift @@ -60,22 +60,29 @@ class GDASpatialDataResultEntity: Object { // MARK: - Computed & Non-Realm Properties - private var _geometry: GeoJsonGeometry? - var geometry: GeoJsonGeometry? { - if _geometry != nil { - return _geometry - } - // Otherwise, try to parse - // TODO: we might want to store that we tried before and failed - guard let geoJSON = coordinatesJson else { + var geometryType: GeometryType? + + private var _coordinates: [Any]? + var coordinates: [Any]? { + if _coordinates != nil { + return _coordinates + } + + // If there aren't coordinates, there is nothing to return + guard let coordinatesJson = coordinatesJson, !coordinatesJson.isEmpty else { return nil } - _geometry = GeoJsonGeometry(geoJSON: geoJSON) - return _geometry - } - - var coordinates: Any? { - return geometry?.coordinates + + // Get the coordinates and the geometry type from the GeoJSON object + let parsedCoordinates = GeometryUtils.coordinates(geoJson: coordinatesJson) + + if let geometryType = parsedCoordinates.type { + self.geometryType = geometryType + } + + _coordinates = parsedCoordinates.points + + return _coordinates } private var _entrances: [POI]? @@ -85,9 +92,6 @@ class GDASpatialDataResultEntity: Object { } // Only POIs with non-point geometries can have entrances - if case .point = geometry { - return nil - } guard coordinates != nil, let jsonData = entrancesJson?.data(using: .utf8) else { return nil @@ -118,8 +122,8 @@ class GDASpatialDataResultEntity: Object { /// Returns the names of properties which Realm should ignore static override func ignoredProperties() -> [String] { - return ["_geometry", - "geometry", + return ["geometryType", + "_coordinates", "coordinates", "_entrances", "entrances"] @@ -178,18 +182,23 @@ class GDASpatialDataResultEntity: Object { } // Set geolocation information - if case .point(let point) = feature.geometry { - latitude = point.latitude - longitude = point.longitude - } else if let json_data = try? JSONEncoder().encode(feature.geometry) { - coordinatesJson = String(data: json_data, encoding: .utf8) - _geometry = feature.geometry; + if let geometry = feature.geometry { + if geometry.type == .point, let point = geometry.point, point.count > 1 { + latitude = point[1] + longitude = point[0] + } else { + coordinatesJson = geometry.coordinateJSON + } + + if let centroid = geometry.centroid { + centroidLatitude = centroid[1] + centroidLongitude = centroid[0] + } else { + centroidLatitude = latitude + centroidLongitude = longitude + } } - let centroid = feature.geometry.centroid - centroidLatitude = centroid.latitude - centroidLongitude = centroid.longitude - // Road specific metadata roundabout = feature.isRoundabout @@ -216,12 +225,14 @@ class GDASpatialDataResultEntity: Object { // MARK: - Geometries /// Returns whether a coordinate lies inside the entity. - /// - note: This is only valid for entities that contain geometries with an area (polygons and multiPolygons), such as buildings. + /// - note: This is only valid for entities that contain geometries, such as buildings. func contains(location: CLLocationCoordinate2D) -> Bool { - guard let geometry = geometry else { - return false - } - return geometry.withinArea(location) + guard let points = self.coordinates as? GAMultiLine else { return false } + let coordinates = points.toCoordinates() + + guard !coordinates.isEmpty else { return false } + + return GeometryUtils.geometryContainsLocation(location: location, coordinates: coordinates.first!) } // MARK: `NSObject` diff --git a/apps/ios/GuideDogs/Code/Data/Models/Cache Models/Intersection.swift b/apps/ios/GuideDogs/Code/Data/Models/Cache Models/Intersection.swift index 26fc218c..c4d3c3d7 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/Cache Models/Intersection.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/Cache Models/Intersection.swift @@ -231,9 +231,12 @@ class Intersection: Object, Locatable, Localizable { self.key += String(roadId) } - if case .point(let coordinate) = feature.geometry { - latitude = coordinate.latitude - longitude = coordinate.longitude + if let lat = feature.geometry?.point?[1] { + latitude = lat + } + + if let lon = feature.geometry?.point?[0] { + longitude = lon } self.key += String(latitude) + String(longitude) diff --git a/apps/ios/GuideDogs/Code/Data/Models/Cache Models/TileData.swift b/apps/ios/GuideDogs/Code/Data/Models/Cache Models/TileData.swift index bcb2839c..c6c1f1c7 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/Cache Models/TileData.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/Cache Models/TileData.swift @@ -73,7 +73,7 @@ class TileData: Object { return VectorTile(quadKey: quadkey) } - convenience init(withParsedData json: GeoJsonFeatureCollection, quadkey: String, etag: String, superCategories: SuperCategories) { + convenience init(withParsedData json: [String: Any], quadkey: String, etag: String, superCategories: SuperCategories) { self.init() // Get the vector tile info @@ -85,7 +85,12 @@ class TileData: Object { // Store the etag for checking future updates self.etag = etag - for feature in json.features { + guard let featuresJson = json["features"] as? [Any] else { return } + + for featureJson in featuresJson { + // Try to parse the feature - the GeoJsonFeature initializer is failable + guard let feature = GeoJsonFeature(json: featureJson as! [String: Any], superCategories: superCategories) else { continue } + // Check if it is a road, intersection, etc. if feature.superCategory == .roads { roads.append(GDASpatialDataResultEntity(feature: feature)!) diff --git a/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResult+Extensions.swift b/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResult+Extensions.swift index d12adfe3..60b958a6 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResult+Extensions.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResult+Extensions.swift @@ -30,8 +30,8 @@ extension GDASpatialDataResultEntity: SelectablePOI { func closestLocation(from location: CLLocation, useEntranceIfAvailable: Bool) -> CLLocation { if useEntranceIfAvailable, let entrance = closestEntrance(from: location) { return entrance.closestLocation(from: location) - } else if let edge = closestEdge(from: location.coordinate) { - return CLLocation(edge) + } else if let edge = closestEdge(from: location) { + return edge } return CLLocation(latitude: latitude, longitude: longitude) diff --git a/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResultEntity+Distance.swift b/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResultEntity+Distance.swift index 7fb6a68c..b382832e 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResultEntity+Distance.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResultEntity+Distance.swift @@ -31,8 +31,59 @@ extension GDASpatialDataResultEntity { return closestEntrance } - func closestEdge(from location: CLLocationCoordinate2D) -> CLLocationCoordinate2D? { - return geometry?.closestEdge(to: location) + func closestEdge(from location: CLLocation) -> CLLocation? { + guard let coordinates = coordinates else { + return nil + } + // If we have coordinates, use those to update the distance and bearing, + // otherwise, use the `latitude` and `longitude` properties + + var closestLocation: CLLocation? + var minimumDistance = CLLocationDistanceMax + + if geometryType == .lineString || geometryType == .multiPoint { + guard let coordinates = coordinates as? GALine else { + return nil + } + + for coordinate in coordinates { + let lat = coordinate[1] + let lon = coordinate[0] + + let newLocation = CLLocation(latitude: lat, longitude: lon) + let distance = newLocation.distance(from: location) + + if distance < minimumDistance { + closestLocation = newLocation + minimumDistance = distance + } + } + } else if geometryType == .multiLineString || geometryType == .polygon { + guard let polygon = coordinates as? GAMultiLine else { + return nil + } + + closestLocation = GeometryUtils.closestEdge(from: location.coordinate, on: polygon) + } else if geometryType == .multiPolygon { + guard let polygons = coordinates as? GAMultiLineCollection else { + return nil + } + + for polygon in polygons { + guard let newLocation = GeometryUtils.closestEdge(from: location.coordinate, on: polygon) else { + continue + } + + let distance = newLocation.distance(from: location) + + if distance < minimumDistance { + closestLocation = newLocation + minimumDistance = distance + } + } + } + + return closestLocation } } diff --git a/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonFeature.swift b/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonFeature.swift index 340c414a..1bc9e9eb 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonFeature.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonFeature.swift @@ -8,40 +8,37 @@ import Foundation -enum GeoJsonFeatureError: Error { - /// Thrown when the json's `type` property is not `"Feature"` - case incorrectTypeField - /// When a feature does not have a name - /// - /// Optionally contains a string containing its `feature_type` and `feature_value`: `=`. - /// - /// While the [GeoJson spec](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2) does not require a name, we are unable to handle features we can't find a name for (Note: I'm not sure why, so I may just be replicating a historical bug). - case nameless(propVal: String?) +struct GeoJsonKeys { + /// Key for accessing the feature_type string of a GeoJson feature + static let featureType = "feature_type" + + /// Key for accessing the feature_value string of a GeoJson feature + static let featureValue = "feature_value" + + /// Key for accessing the geometry object of a GeoJson feature + static let geometry = "geometry" + + /// Key for accessing the OSM IDs array of a GeoJson feature + static let osmIds = "osm_ids" + + /// Key for accessing the priority integer of a GeoJson feature + static let priority = "priority" + + /// Key for accessing the properties dictionary of a GeoJson feature + static let properties = "properties" + + /// Key for accessing the name property from the properties dictionary of a GeoJson feature + static let name = "name" + + /// Key for accessing the ref property from the properties dictionary of a GeoJson feature + static let ref = "ref" + + /// Prefix string which all localized names have in the properties dictionary of a GeoJson feature + static let i18nNamePrefix = "name:" } -final class GeoJsonFeature: Decodable { - enum CodingKeys: String, CodingKey { - /// Key for accessing the `feature_type` property of a GeoJson feature - case feature_type - /// Key for accessing the `feature_value` property of a GeoJson feature - case feature_value - /// Key for accessing the `geometry` (``GeoJsonGeometry``) object of a GeoJson feature - case geometry - /// Key for accessing the OSM IDs array of a GeoJson feature - case osm_ids - /// Key for accessing the `priority` integer of a GeoJson feature - case priority - /// Key for accessing the `properties` dictionary of a GeoJson feature - case properties - /// Key for accessing the `name` property from the properties dictionary of a GeoJson feature - case name - /// Key for accessing the `ref` property from the properties dictionary of a GeoJson feature - case ref - /// Prefix string which all localized names have in the properties dictionary of a GeoJson feature - case i18nNamePrefix = "name:" - /// Key for the json `type`, should always be `"Feature"` - case type - } +class GeoJsonFeature { + // MARK: Properties /// Name of the feature @@ -80,9 +77,7 @@ final class GeoJsonFeature: Decodable { var superCategory: SuperCategory = .undefined /// Geometry of this feature - /// - /// GeoJSON spec allows null geometries, but we don't (we throw a parse failure if missing) - var geometry: GeoJsonGeometry + var geometry: GeoJsonGeometry? var isCrossing = false @@ -90,27 +85,18 @@ final class GeoJsonFeature: Decodable { // MARK: Initializers - /// ``JSONDecoder`` - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Object's `type` property must be "Feature" - let parsed_type = try container.decode(String.self, forKey: .type) - guard parsed_type == "Feature" else { - throw GeoJsonFeatureError.incorrectTypeField + init?(json: [String: Any], superCategories: SuperCategories) { + // Parse the OSM IDs + if let ids = GeoJsonFeature.extractIDs(from: json) { + osmIds = ids } - // Parse OSM IDs - let parsed_ids = try? container.decode([Int].self, forKey: .osm_ids) - osmIds = parsed_ids?.map({ "ft\($0)" }) ?? [] - // Parse the general feature information - let parsed_feature_type = try? container.decode(String.self, forKey: .feature_type) - type = parsed_feature_type ?? "" - let parsed_feature_value = try? container.decode(String.self, forKey: .feature_value) - value = parsed_feature_value ?? "" + type = json[GeoJsonKeys.featureType] as? String ?? "" + value = json[GeoJsonKeys.featureValue] as? String ?? "" + + let nameObjects = GeoJsonFeature.extractNames(from: json) - let nameObjects = GeoJsonFeature.extractNames(from: container) // Entities should have a name or a tag (which will be transformed to a localized name at runtime) guard nameObjects.name != nil || nameObjects.tag != nil else { // Collect information about this nameless feature for later analysis @@ -120,21 +106,28 @@ final class GeoJsonFeature: Decodable { GeoJsonFeature.unhandledNamelessFeatures.insert(propVal) } - throw GeoJsonFeatureError.nameless(propVal: propVal) + return nil } name = nameObjects.name names = nameObjects.names nameTag = nameObjects.tag - // note: `properties` isn't required by spec to be [String: String], but appears to always be in data from OSM - if let featureProperties = try? container.decode([String: String].self, forKey: .properties) { - self.properties = featureProperties + let featureProperties = json[GeoJsonKeys.properties] as? [String: String] + + if let properties = featureProperties { + self.properties = properties - if let ref = properties[CodingKeys.ref.rawValue] { + if let ref = properties[GeoJsonKeys.ref] { self.ref = ref } } + + // Parse the geometry information - features must have a geometry according to the GeoJSON spec + guard let geoData = json[GeoJsonKeys.geometry] as? [String: Any] else { + return nil + } + if GeoJsonFeature.hasTag("footway=crossing", props: properties) || nameObjects.tag == GeoJsonFeature.FeatureNameTag.crossing { isCrossing = true @@ -144,18 +137,26 @@ final class GeoJsonFeature: Decodable { isRoundabout = true } - // GeoJSON spec permits null geometries, but we don't - geometry = try container.decode(GeoJsonGeometry.self, forKey: .geometry) + // Ensure we have a valid geometry or return nil otherwise + guard let geo = GeoJsonGeometry(geoJSON: geoData) else { + return nil + } // Fix geometries for crossings with LineString geometries - if isCrossing, case .lineString = geometry, - let median = geometry.getLineMedian() { - geometry = GeoJsonGeometry(point: median) + if isCrossing && geo.type == .lineString { + if let median = geo.getLineMedian() { + geometry = GeoJsonGeometry(point: median) + } else { + geometry = geo + } + } else { + geometry = geo } // Parse the priority of the feature - let parsedPriority = try? container.decode(UInt.self, forKey: .priority) - priority = parsedPriority ?? 0 + if let parsedPriority = json[GeoJsonKeys.priority] as? UInt { + priority = parsedPriority + } // // Deal with super categories and missing names: @@ -223,8 +224,6 @@ final class GeoJsonFeature: Decodable { } } -#if false - // TODO: this is currently unused (we never passed in any supercategories, even before refactoring), but should probably be reviewed to determine if it should get implemented // General Case: look up the category in the list of categories we got from the server var applicableCategories: Set = [] for (key, value) in properties { @@ -239,7 +238,6 @@ final class GeoJsonFeature: Decodable { if let prioritizedCategory = GeoJsonFeature.prioritizedCategories.first(where: { applicableCategories.contains($0) }) { superCategory = prioritizedCategory } -#endif } init(copyFrom: GeoJsonFeature) { @@ -263,23 +261,28 @@ final class GeoJsonFeature: Decodable { self.properties[key] = value } - // enums are pass-by-value self.priority = copyFrom.priority self.superCategory = copyFrom.superCategory - self.geometry = copyFrom.geometry + self.geometry = GeoJsonGeometry(copyFrom: copyFrom.geometry) } func decomposePathToStartAndEndCrossings() -> (start: GeoJsonFeature, end: GeoJsonFeature)? { let startCopy = GeoJsonFeature(copyFrom: self) let endCopy = GeoJsonFeature(copyFrom: self) - startCopy.geometry = .point(coordinates: geometry.first) - startCopy.superCategory = SuperCategory.mobility - - endCopy.geometry = .point(coordinates: geometry.last) - endCopy.superCategory = SuperCategory.mobility + guard let createdStart = startCopy.geometry?.clipToFirstPoint(), let createdEnd = endCopy.geometry?.clipToLastPoint() else { + return nil + } + + if createdStart && createdEnd { + // Make crossing start and end points mobility POIs by default + startCopy.superCategory = SuperCategory.mobility + endCopy.superCategory = SuperCategory.mobility - return (startCopy, endCopy) + return (startCopy, endCopy) + } + + return nil } /// Check if a set of properties contains a key-value pair matching the input tag. Tags are @@ -332,28 +335,28 @@ final class GeoJsonFeature: Decodable { extension GeoJsonFeature { fileprivate static func extractIDs(from json: [String: Any]) -> [String]? { - guard let ids = json[CodingKeys.osm_ids.rawValue] as? [Int] else { return nil } + guard let ids = json[GeoJsonKeys.osmIds] as? [Int] else { return nil } return ids.map({ (id) -> String in return "ft\(id)" }) } - fileprivate static func extractNames(from container: KeyedDecodingContainer) -> (name: String?, names: [String: String]?, tag: String?) { - guard let properties = try? container.decode([String: String].self, forKey: .properties) else { - return (nil, nil, nil) - } + fileprivate static func extractNames(from json: [String: Any]) -> (name: String?, names: [String: String]?, tag: String?) { + guard let properties = json[GeoJsonKeys.properties] as? [String: String] else { return (nil, nil, nil) } - if let value = try? container.decode(String.self, forKey: .feature_value), - value == "gd_intersection" { + if let value = json[GeoJsonKeys.featureValue] as? String { // Special case: intersections (calculated intersections have value "gd_intersection" by definition) - return (name: GDLocalizedString("osm.tag.intersection"), nil, "intersection") + if value == "gd_intersection" { + return (name: GDLocalizedString("osm.tag.intersection"), nil, "intersection") + } } - if let type = try? container.decode(String.self, forKey: .feature_type), - type == "gd_entrance_list"{ + if let type = json[GeoJsonKeys.featureType] as? String { // Special case: entrance list (calculated entrance lists have type "gd_entrance_list" by definition) - return (name: GDLocalizedString("directions.amenity.entrance_list"), nil, nil) + if type == "gd_entrance_list" { + return (name: GDLocalizedString("directions.amenity.entrance_list"), nil, nil) + } } var name: String? @@ -361,12 +364,12 @@ extension GeoJsonFeature { // Try to extract name and i18n names for (property, value) in properties { - if property == CodingKeys.name.rawValue { + if property == GeoJsonKeys.name { name = value continue } - if let range = property.range(of: CodingKeys.i18nNamePrefix.rawValue) { + if let range = property.range(of: GeoJsonKeys.i18nNamePrefix) { var languageCode = property languageCode.removeSubrange(range) @@ -379,7 +382,7 @@ extension GeoJsonFeature { } // Custom name extractions - if name == nil, let featureValue = try? container.decode(String.self, forKey: .feature_value) { + if name == nil, let featureValue = json[GeoJsonKeys.featureValue] as? String { if featureValue == "atm", let atm = properties["operator"], atm.count > 0 { name = atm } else if featureValue == "bank" { diff --git a/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonGeometry.swift b/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonGeometry.swift index e370ace9..a7f7d41d 100644 --- a/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonGeometry.swift +++ b/apps/ios/GuideDogs/Code/Data/Models/JSON Parsing/OSM/GeoJsonGeometry.swift @@ -7,37 +7,36 @@ // import Foundation -import CoreLocation -/// GeoJsonGeometry is a Swift representation of the `geometry` property in a GeoJSON feature. -/// Geometry types from the GeoJSON [spec](https://tools.ietf.org/html/rfc7946). -/// -/// - `point`: The geometry consists of a single point -/// - `lineString`: The geometry consists of at least two points -/// - `multiPoint`: The geometry consists of any number of points -/// - `polygon`: The geometry consists of an array of linear ring coordinate arrays (see spec above) -/// - `multiLineString`: The geometry consists of an array of lineStrings -/// - `multiPolygon`: The geometry consists of an array of polygons +/// Geometry types from the GeoJSON spec ([link](https://tools.ietf.org/html/rfc7946)). +/// Note that this looks like a very odd/inefficient way to define an `Enum:String` type. That is +/// because it is. This was only defined this way so as to allow for Objective-C support. In +/// Objective-C, enums must have an integer type, hence the additional work to allow for strings. /// -/// Note: every `coordinates` list and sub-list should have at least one point in it (or more depending on spec) -public enum GeoJsonGeometry: Equatable, Codable { +/// - point: The geometry consists of a single point +/// - lineString: The geometry consists of at least two points +/// - multiPoint: The geometry consists of any number of points +/// - polygon: The geometry consists of an array of linear ring coordinate arrays (see spec above) +/// - multiLineString: The geometry consists of an array of lineStrings +/// - multiPolygon: The geometry consists of an array of polygons +@objc public enum GeometryType: Int, RawRepresentable { /// The geometry consists of a single point - case point(coordinates: CLLocationCoordinate2D) + case point /// The geometry consists of at least two points - case lineString(coordinates: [CLLocationCoordinate2D]) + case lineString /// The geometry consists of any number of points - case multiPoint(coordinates: [CLLocationCoordinate2D]) + case multiPoint /// The geometry consists of an array of linear ring coordinate arrays (see spec above) - case polygon(coordinates: [[CLLocationCoordinate2D]]) + case polygon /// The geometry consists of an array of lineStrings - case multiLineString(coordinates: [[CLLocationCoordinate2D]]) + case multiLineString /// The geometry consists of an array of polygons - case multiPolygon(coordinates: [[[CLLocationCoordinate2D]]]) + case multiPolygon public typealias RawValue = String @@ -58,312 +57,294 @@ public enum GeoJsonGeometry: Equatable, Codable { } } - enum GeoJsonGeometryError: Error { - /// When the `coordinates` property is of the correct type, but is semantically incorrect (e.g. being empty, or missing longitudes) - case invalidCoordinates - /// A general error - case notParsable - } - - enum CodingKeys: String, CodingKey { - case type - case coordinates - } - - /// Decodes JSON or similarly structured decoders - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - let typeString = try values.decode(String.self, forKey: .type) - switch typeString { + public init?(rawValue: RawValue) { + switch rawValue { case "Point": - let coord = try values.decode([Double].self, forKey: .coordinates) - guard let x = GeoJsonGeometry.toCoordinate(coord) else { - throw GeoJsonGeometryError.invalidCoordinates - } - self = .point(coordinates: x) + self = .point case "LineString": - let coords = try values.decode([[Double]].self, forKey: .coordinates) - guard let x = GeoJsonGeometry.toCoordinates(coords), x.count >= 2 else { - throw GeoJsonGeometryError.invalidCoordinates - } - self = .lineString(coordinates: x) + self = .lineString case "MultiPoint": - let coords = try values.decode([[Double]].self, forKey: .coordinates) - guard let x = GeoJsonGeometry.toCoordinates(coords), x.count > 0 else { - throw GeoJsonGeometryError.invalidCoordinates - } - self = .multiPoint(coordinates: x) + self = .multiPoint case "Polygon": - // Ensuring correctness: https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6 - let coords = try values.decode([[[Double]]].self, forKey: .coordinates) - guard let x = GeoJsonGeometry.toCoordinates(coords), - x.first?.first != nil, - x.allSatisfy({ $0.count >= 4 && $0.first == $0.last }) else { - throw GeoJsonGeometryError.invalidCoordinates - } - self = .polygon(coordinates: x) + self = .polygon case "MultiLineString": - let coords = try values.decode([[[Double]]].self, forKey: .coordinates) - guard let x = GeoJsonGeometry.toCoordinates(coords), - x.count > 0, x.allSatisfy({ $0.count >= 2 }) else { - throw GeoJsonGeometryError.invalidCoordinates - } - self = .multiLineString(coordinates: x) + self = .multiLineString case "MultiPolygon": - let coords = try values.decode([[[[Double]]]].self, forKey: .coordinates) - guard let x = GeoJsonGeometry.toCoordinates(coords), - x.first?.first?.first != nil else { - throw GeoJsonGeometryError.invalidCoordinates - } - self = .multiPolygon(coordinates: x) + self = .multiPolygon default: - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: values.codingPath, debugDescription: "Invalid GeoJsonGeometry type \"\(typeString)\"")) + self = .multiPolygon } } +} + +/// GeoJsonGeometry is a Swift representation of the `geometry` property in a GeoJSON feature. +class GeoJsonGeometry { + + /// Type of this geometry from the parsed GeoJSON + private(set) var type: GeometryType - public init?(geoJSON: String) { - guard let data = geoJSON.data(using: .utf8), - let json = try? JSONDecoder().decode(GeoJsonGeometry.self, from: data) else { + /// String encoding of the coordinates in this geometry + private(set) var coordinateJSON: String + + /// Coordinates from the parsed GeoJSON + private(set) var coordinates: [[[[Double]]]]? + + /// A single lat/lon coordinate in the case self.type is `Point` + var point: [Double]? { + guard type == .point else { return nil } - self = json - } - - public init(point: CLLocationCoordinate2D) { - self = .point(coordinates: point) + + return coordinates?[0][0][0] } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.rawValue, forKey: .type) - switch self { - case .point(coordinates: let coordinates): - try container.encode([coordinates.longitude, coordinates.latitude], forKey: .coordinates) - case .lineString(coordinates: let coordinates), .multiPoint(coordinates: let coordinates): - let expanded = coordinates.map(GeoJsonGeometry.into_coord_pair) - try container.encode(expanded, forKey: .coordinates) - case .polygon(coordinates: let coordinates), .multiLineString(coordinates: let coordinates): - let expanded = coordinates.map({$0.map(GeoJsonGeometry.into_coord_pair)}) - try container.encode(expanded, forKey: .coordinates) - case .multiPolygon(coordinates: let coordinates): - let expanded = coordinates.map({$0.map({$0.map(GeoJsonGeometry.into_coord_pair)})}) - try container.encode(expanded, forKey: .coordinates) + /// An array of points in the case self.type is `LineString` or `MultiPoint` + var points: [[Double]]? { + guard type == .lineString || type == .multiPoint else { + return nil } + + return coordinates?[0][0] } - /// note: the centroid is based on the implementation in ``GeometryUtils`` and may have any of the same issues. - var centroid: CLLocationCoordinate2D { - switch self { - case .point(coordinates: let coordinates): - return coordinates - case .lineString(coordinates: let coordinates), .multiPoint(coordinates: let coordinates): - return GeometryUtils.centroid(coordinates: coordinates)! - case .polygon(coordinates: let coordinates), .multiLineString(coordinates: let coordinates): - return GeometryUtils.centroid(coordinates: coordinates.flatMap({$0}))! - case .multiPolygon(coordinates: let coordinates): - return GeometryUtils.centroid(coordinates: coordinates.flatMap({$0.flatMap({$0})}))! + /// An array of lines in the case self.type is `Polygon` or `MultiLineString` + var polygon: [[[Double]]]? { + guard type == .polygon || type == .multiLineString else { + return nil } + + return coordinates?[0] } - /// Finds the median point in the list of points, or if there are an even number of points, gets the mid point between the two median points. Returns nil if the geometry isn't a LineString - func getLineMedian() -> CLLocationCoordinate2D? { - guard case .lineString(coordinates: let coordinates) = self else { + /// An array of polygons in the case self.type is `MultiPolygon` + var multipolygon: [[[[Double]]]]? { + guard type == .multiPolygon else { return nil } - guard !coordinates.isEmpty else { + return coordinates + } + + /// The geometry's centroid, represented as `[longitude, latitude]` + var centroid: [Double]? { + guard let points = coordinates else { return nil } - if coordinates.count % 2 == 1 { - return coordinates[coordinates.count / 2] + let flattenedCoordinates = Array(points.toCoordinates().joined().joined()) + guard let centroid = GeometryUtils.centroid(coordinates: flattenedCoordinates) else { + return nil } - let first = coordinates[(coordinates.count / 2) - 1] - let last = coordinates[coordinates.count / 2] - return first.coordinateBetween(coordinate: last, distance: first.distance(from: last) / 2) + return [centroid.longitude, centroid.latitude] } - /// Find the very first point - in the typical line case, this works just fine. In the multipolygon sort of case, this will be weird - var first: CLLocationCoordinate2D { - switch self { - case .point(coordinates: let coordinates): - return coordinates - case .lineString(coordinates: let coordinates): - return coordinates.first! - case .multiPoint(coordinates: let coordinates): - return coordinates.first! - case .polygon(coordinates: let coordinates): - return coordinates.first!.first! - case .multiLineString(coordinates: let coordinates): - return coordinates.first!.first! - case .multiPolygon(coordinates: let coordinates): - return coordinates.first!.first!.first! + init?(geoJSON: [String: Any]) { + // Parse the geometry type + guard let typeString = geoJSON["type"] as? String else { + return nil + } + + guard let parsedType = GeometryType(rawValue: typeString) else { + return nil + } + + type = parsedType + + // Save the coordinates JSON + do { + let data = try JSONSerialization.data(withJSONObject: geoJSON) + coordinateJSON = String(data: data, encoding: String.Encoding.utf8)! + } catch { + return nil + } + + switch type { + case .point: + if let pt = geoJSON["coordinates"] as? [Double] { + coordinates = [[[pt]]] + } + case .lineString: + if let ln = geoJSON["coordinates"] as? [[Double]] { + coordinates = [[ln]] + } + case .multiPoint: + if let ln = geoJSON["coordinates"] as? [[Double]] { + coordinates = [[ln]] + } + case .polygon: + if let poly = geoJSON["coordinates"] as? [[[Double]]] { + coordinates = [poly] + } + case .multiLineString: + if let poly = geoJSON["coordinates"] as? [[[Double]]] { + coordinates = [poly] + } + case .multiPolygon: + if let poly = geoJSON["coordinates"] as? [[[[Double]]]] { + coordinates = poly + } + } + + // Validate that the provided coordinates are valid and not malformed + guard validate() else { + return nil } } - /// Find the very last point - in the typical line case, this works just fine. In the multipolygon sort of case, this will be weird - var last: CLLocationCoordinate2D { - switch self { - case .point(coordinates: let coordinates): - return coordinates - case .lineString(coordinates: let coordinates): - return coordinates.last! - case .multiPoint(coordinates: let coordinates): - return coordinates.last! - case .polygon(coordinates: let coordinates): - return coordinates.last!.last! - case .multiLineString(coordinates: let coordinates): - return coordinates.last!.last! - case .multiPolygon(coordinates: let coordinates): - return coordinates.last!.last!.last! + init?(point: [Double]) { + guard point.count == 2 else { + return nil } + + type = .point + coordinates = [[[point]]] + coordinateJSON = "{\"coordinates\": [\(point[0]),\(point[1])], \"type\": \"Point\"}" } - - var coordinates: Any { - switch self { - case .point(coordinates: let coordinates): - return coordinates - case .lineString(coordinates: let coordinates): - return coordinates - case .multiPoint(coordinates: let coordinates): - return coordinates - case .polygon(coordinates: let coordinates): - return coordinates - case .multiLineString(coordinates: let coordinates): - return coordinates - case .multiPolygon(coordinates: let coordinates): - return coordinates + + init?(copyFrom: GeoJsonGeometry?) { + guard let copy = copyFrom else { + return nil } + self.type = copy.type + self.coordinateJSON = copy.coordinateJSON + self.coordinates = copy.coordinates } - /// If the geometry contains the point. - /// Since only `polygon` and `multiPolygon` geometries have any area (and thus contain stuff), all other geometry types will return false. - /// - /// true if within the region of the first (outer) ring, but none of the other rings (holes) - func withinArea(_ point: CLLocationCoordinate2D) -> Bool { - switch self { - case .polygon(coordinates: let coordinates): - guard GeometryUtils.geometryContainsLocation(location: point, coordinates: coordinates.first!) else { - return false + func validate() -> Bool { + switch type { + case .point: + // A point must have 2 values (lat, lon) + if let point = point, point.count == 2 { + return true } - for i in 1.. 0 { + return !points.contains(where: { $0.count != 2 }) } - return true - case .multiPolygon(coordinates: let polys): - return polys.contains(where: { poly in - guard GeometryUtils.geometryContainsLocation(location: point, coordinates: poly.first!) else { - return false - } - for i in 1.. CLLocationCoordinate2D? { - switch self { - case .point(coordinates: let coordinates): - return coordinates - case .lineString(coordinates: let coordinates), .multiPoint(coordinates: let coordinates): - return GeometryUtils.closestEdge(from: point, on: coordinates) - case .polygon(coordinates: let coordinates), .multiLineString(coordinates: let coordinates): - var closestLocation: CLLocationCoordinate2D? = nil - var minimumDistance = CLLocationDistanceMax - for line in coordinates { - guard let closest = GeometryUtils.closestEdge(from: point, on: line) else { - continue - } - let distance = closest.distance(from: point) - if distance < minimumDistance { - closestLocation = closest - minimumDistance = distance - } + + case .polygon, .multiLineString: + // A polygon or multiLineString geometry must have 1 or more linesStrings, each with 1 or more points, and those points must have 2 values (lat, lon) + if let polygon = polygon, polygon.count > 0 { + return !polygon.contains(where: { (points) -> Bool in + return points.count == 0 || points.contains(where: { $0.count != 2}) + }) } - return closestLocation - case .multiPolygon(coordinates: let coordinates): - var closestLocation: CLLocationCoordinate2D? = nil - var minimumDistance = CLLocationDistanceMax - for polygon in coordinates { - for line in polygon { - guard let closest = GeometryUtils.closestEdge(from: point, on: line) else { - continue - } - let distance = closest.distance(from: point) - if distance < minimumDistance { - closestLocation = closest - minimumDistance = distance - } - } + + case .multiPolygon: + // A multipolygon geometry must have 1 or more polygons, each with 1 or more linesStrings, each with 1 or more points, and those points must have 2 values (lat, lon) + if let multipolygon = multipolygon, multipolygon.count > 0 { + return !multipolygon.contains(where: { (polygon) -> Bool in + return polygon.count == 0 || polygon.contains(where: { (points) -> Bool in + return points.count == 0 || points.contains(where: { $0.count != 2 }) + }) + }) } - return closestLocation } + + return false } -} - - -// MARK: Conversion Functions - -extension GeoJsonGeometry { - private static func into_coord_pair(_ coord: CLLocationCoordinate2D) -> [CLLocationDegrees] { - return [coord.latitude, coord.longitude] - } - - - /// Transform to a `CLLocationCoordinate2D` object. - private static func toCoordinate(_ arr: [Double]?) -> CLLocationCoordinate2D? { - guard let arr = arr, arr.count >= 2 else { + /// Finds the median point in the list of points, or if there are an even number of points, gets the mid point between the two median points. Returns nil if the geometry isn't a LineString + func getLineMedian() -> [Double]? { + guard type == .lineString else { return nil } - return CLLocationCoordinate2DMake(arr[1], arr[0]) - } - /// Transform to an array of `CLLocationCoordinate2D` objects. - private static func toCoordinates(_ arr: [[Double]]?) -> [CLLocationCoordinate2D]? { - guard let arr = arr else { + + guard let pts = coordinates?[0][0] else { return nil } - return try? arr.map({ (point) -> CLLocationCoordinate2D in - guard let coord = toCoordinate(point) else { - throw GeoJsonGeometryError.notParsable + + guard pts.count > 1 else { + if pts.count == 1 { + return pts[0] } - return coord - }) - } - /// Transform to an array of `CLLocationCoordinate2D` objects. - private static func toCoordinates(_ arr: [[[Double]]]?) -> [[CLLocationCoordinate2D]]? { - guard let arr = arr else { + return nil } - return try? arr.map({ (point) -> [CLLocationCoordinate2D] in - guard let coord = toCoordinates(point) else { - throw GeoJsonGeometryError.notParsable + + if pts.count % 2 == 1 { + return pts[pts.count / 2] + } + + let first = pts[(pts.count / 2) - 1] + let last = pts[pts.count / 2] + + let toRadians = .pi / 180.0 + let toDegrees = 180.0 / .pi + + let phi1 = first[1] * toRadians + let phi2 = last[1] * toRadians + let lambda1 = first[0] * toRadians + let lambda2 = last[0] * toRadians + + let bX = cos(phi2) * cos(lambda2 - lambda1) + let bY = cos(phi2) * sin(lambda2 - lambda1) + let lat = toDegrees * atan2(sin(phi1) + sin(phi2), sqrt((cos(phi1) + bX) * (cos(phi1) + bX) + bY * bY)) + let lon = fmod((lambda1 + atan2(bY, cos(phi1) + bX)) * toDegrees + 540, 360.0) - 180.0 + + return [lon, lat] + } + + func clipToFirstPoint() -> Bool { + guard let firstPoint = coordinates?[0][0][0] else { + return false + } + + do { + let jsonObj = try JSONSerialization.data(withJSONObject: ["coordinates": firstPoint, "type": "Point"]) + + guard let jsonStr = String(data: jsonObj, encoding: String.Encoding.utf8) else { + return false } - return coord - }) + + // We were able to properly encode the new clipped geometry, so update everything + type = .point + coordinates = [[[firstPoint]]] + coordinateJSON = jsonStr + } catch { + return false + } + + return true } - /// Transform to an array of `CLLocationCoordinate2D` objects. - private static func toCoordinates(_ arr: [[[[Double]]]]?) -> [[[CLLocationCoordinate2D]]]? { - guard let arr = arr else { - return nil + + func clipToLastPoint() -> Bool { + // Find the very last point - in the typical line case, this works just fine. In the multipolygon sort of case, this will be weird + guard let firstIdx = coordinates?.count else { + return false + } + + guard let secondIdx = coordinates?[firstIdx - 1].count else { + return false } - return try? arr.map({ (point) -> [[CLLocationCoordinate2D]] in - guard let coord = toCoordinates(point) else { - throw GeoJsonGeometryError.notParsable + + guard let thirdIdx = coordinates?[firstIdx - 1][secondIdx - 1].count else { + return false + } + + guard let lastPoint = coordinates?[firstIdx - 1][secondIdx - 1][thirdIdx - 1] else { + return false + } + + do { + let jsonObj = try JSONSerialization.data(withJSONObject: ["coordinates": lastPoint, "type": "Point"]) + + guard let jsonStr = String(data: jsonObj, encoding: String.Encoding.utf8) else { + return false } - return coord - }) + + // We were able to properly encode the new clipped geometry, so update everything + type = .point + coordinates = [[[lastPoint]]] + coordinateJSON = jsonStr + } catch { + return false + } + + return true } } diff --git a/apps/ios/GuideDogs/Code/Data/Services/Helpers/ServiceModel.swift b/apps/ios/GuideDogs/Code/Data/Services/Helpers/ServiceModel.swift index 14888b38..ad246a94 100644 --- a/apps/ios/GuideDogs/Code/Data/Services/Helpers/ServiceModel.swift +++ b/apps/ios/GuideDogs/Code/Data/Services/Helpers/ServiceModel.swift @@ -148,7 +148,7 @@ class ServiceModel { return json as? [String: Any] } - static func validateJsonResponse(request: URLRequest, response: URLResponse?, data: Data?, error: Error?, callback: @escaping (HTTPStatusCode, Error?) -> Void) -> (HTTPStatusCode, String, GeoJsonFeatureCollection)? { + static func validateJsonResponse(request: URLRequest, response: URLResponse?, data: Data?, error: Error?, callback: @escaping (HTTPStatusCode, Error?) -> Void) -> (HTTPStatusCode, String, [String: Any])? { // Some more housekeeping ServiceModel.logNetworkResponse(response, request: request, error: error) @@ -194,7 +194,7 @@ class ServiceModel { } // If we get this far, then the data property should not be nil, and it should be valid JSON - guard let data = data, let parsed = try? JSONDecoder().decode(GeoJsonFeatureCollection.self, from: data) else { + guard let data = data, let parsed = try? JSONSerialization.jsonObject(with: data), let json = parsed as? [String: Any] else { DispatchQueue.main.async { callback(status, ServiceError.jsonParseFailed) } @@ -202,7 +202,7 @@ class ServiceModel { return nil } - return (status, newEtag, parsed) + return (status, newEtag, json) } static func logNetworkRequest(_ request: URLRequest) { diff --git a/apps/ios/GuideDogs/Code/Generators/Geocoding/ReverseGeocoderContext.swift b/apps/ios/GuideDogs/Code/Generators/Geocoding/ReverseGeocoderContext.swift index b036378d..c6dbb98d 100644 --- a/apps/ios/GuideDogs/Code/Generators/Geocoding/ReverseGeocoderContext.swift +++ b/apps/ios/GuideDogs/Code/Generators/Geocoding/ReverseGeocoderContext.swift @@ -267,18 +267,22 @@ class ReverseGeocoderContext: ReverseGeocoder { continue } - guard let points = osmEntity.geometry?.coordinates as? [CLLocationCoordinate2D] else { continue } + guard let points = osmEntity.coordinates as? GALine else { continue } let isStickyRoad = stickyRoad?.localizedName == road.localizedName var previous: CLLocationCoordinate2D? for point in points { - let prev = previous ?? point - let current = point + guard previous != nil else { + previous = point.toCoordinate() + continue + } + + let current = point.toCoordinate() // Calculate the distance from the user's location to the road segment let (dist, lat, lon) = GeometryUtils.squaredDistance(location: usersLocation.coordinate, - start: prev, + start: previous!, end: current, zoom: zoomLevel) diff --git a/apps/ios/GuideDogs/GeoJsonFeatureCollection.swift b/apps/ios/GuideDogs/GeoJsonFeatureCollection.swift deleted file mode 100644 index 334a6b02..00000000 --- a/apps/ios/GuideDogs/GeoJsonFeatureCollection.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// GeoJsonFeatureCollection.swift -// Soundscape -// -// Created by Kai on 11/10/23. -// Copyright © 2023 Soundscape community. All rights reserved. -// - -import Foundation - -final class FailableDecode: Decodable { - var result: Result - - public init(from decoder: Decoder) throws { - result = Result(catching: { try T(from: decoder) }) - } -} - -/// Represents the parsed json response from the OSM tiles service -final class GeoJsonFeatureCollection: Decodable { - var features: [GeoJsonFeature] - - private enum CodingKeys: CodingKey { - /// Contains an array of ``GeoJsonFeature``s - case features - /// Should always be `"FeatureCollection"` - case type - } - - enum GeoJsonFeatureCollectionParseError: Error { - /// The `type` property of an ``GeoJsonFeatureCollection`` should always be `"FeatureCollection"` - case incorrectTypeField - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - guard type == "FeatureCollection" else { - throw GeoJsonFeatureCollectionParseError.incorrectTypeField - } - - /// Some parsed features may error, since our ``GeoJsonFeature`` implementation requires a name - /// As a result, we simply filter out the failing ones - let parsed_features = try container.decode([FailableDecode].self, forKey: .features) - features = parsed_features.compactMap({ - switch $0.result { - case .success(let feature): return feature - case .failure(_): return nil - } - }) - - } -} diff --git a/apps/ios/UnitTests.xctestplan b/apps/ios/UnitTests.xctestplan index a09a331a..c4e2fa5b 100644 --- a/apps/ios/UnitTests.xctestplan +++ b/apps/ios/UnitTests.xctestplan @@ -32,9 +32,6 @@ }, "testTargets" : [ { - "skippedTests" : [ - "AudioEngineTest" - ], "target" : { "containerPath" : "container:GuideDogs.xcodeproj", "identifier" : "914DEBCC2A3CE6B9007B161C", diff --git a/apps/ios/UnitTests/App/Helpers/GeometryUtilsTest.swift b/apps/ios/UnitTests/App/Helpers/GeometryUtilsTest.swift index d46d9416..be1905ad 100644 --- a/apps/ios/UnitTests/App/Helpers/GeometryUtilsTest.swift +++ b/apps/ios/UnitTests/App/Helpers/GeometryUtilsTest.swift @@ -12,6 +12,132 @@ import CoreLocation class GeometryUtilsTest: XCTestCase { + // TODO: `GeometryUtils::coordinates(geoJson:)` would be better if the `GeometryType` enum used associated values (coordinates), which would let us avoid the fact that it currently returns a vague `[Any]?` and instead just return a `GeometryType`. According to comments in `GeometryUtils`, the reason for this is compatibility with Objective-C. However, if we can move away from that, we could have much better code. + + // GeoJSON strings taken/adapted from the GeoJSON spec, RFC-7946 + + /// normal test case for `GeometryUtils::coordinates(geoJson:)` + func testGeoJSONCoordinates_Point() throws { + /// `Point`-- coordinates are a `[Double]` + let point = GeometryUtils.coordinates(geoJson: """ +{ + "type": "Point", + "coordinates": [100.0, 0.0] +} +""") + XCTAssertEqual(point.type, GeometryType.point) + XCTAssertEqual(point.points as! [Double], [100.0, 0.0]) + } + + /// normal test case for `GeometryUtils::coordinates(geoJson:)` + func testGeoJSONCoordinates_LineString() throws { + /// `LineString`-- coordinates are a `[[Double]]` + let lineString = GeometryUtils.coordinates(geoJson: """ +{ + "type": "LineString", + "coordinates": [ + [100.0, 0.0], + [101.0, 1.0] + ] +} +""") + XCTAssertEqual(lineString.type, GeometryType.lineString) + XCTAssertEqual(lineString.points as! [[Double]], [[100.0, 0.0], [101.0, 1.0]]) + } + /// normal test case for `GeometryUtils::coordinates(geoJson:)` + func testGeoJSONCoordinates_Polygon() throws { + /// `Polygon`-- coordinates are a `[[[Double]]]` + let poly = GeometryUtils.coordinates(geoJson: """ +{ + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ], + [ + [100.8, 0.8], + [100.8, 0.2], + [100.2, 0.2], + [100.2, 0.8], + [100.8, 0.8] + ] + ] +} +""") + XCTAssertEqual(poly.type, GeometryType.polygon) + XCTAssertEqual(poly.points as! [[[Double]]], [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ], + [ + [100.8, 0.8], + [100.8, 0.2], + [100.2, 0.2], + [100.2, 0.8], + [100.8, 0.8] + ] + ]) + } + + // Skipping type `MultiPoint` as equivalent + // Skipping type `MultiLineString` as equivalent + // Skipping type `MultiPolygon` as equivalent + + func testGeoJSONCoordinates_invalidType() throws { + let a = GeometryUtils.coordinates(geoJson: """ +{ + "type": "a", + "coordinates": [100.0, 0.0] +} +""") + // TODO: apparently invalid types become GeometryType.multiPolygon - should it? + XCTAssertEqual(a.type, .multiPolygon) + XCTAssertEqual(a.points as! [Double], [100.0, 0.0]) + } + + /// edge case for `GeometryUtils::coordinates(geoJson:)` with empty input + /// which should result in `(nil, nil)` + func testGeoJSONCoordinates_emptystring() throws { + let emptyString = GeometryUtils.coordinates(geoJson: "") + XCTAssertNil(emptyString.type) + XCTAssertNil(emptyString.points) + } + + /// edge cases for `GeometryUtils::coordinates(geoJson:)` with malformed json + /// which should result in `(nil, nil)` + func testGeoJSONCoordinates_malformed() throws { + let badKey = GeometryUtils.coordinates(geoJson: "{a: 1}"); + XCTAssertNil(badKey.type) + XCTAssertNil(badKey.points) + + let badValue = GeometryUtils.coordinates(geoJson: "{\"a\": asdf}") + XCTAssertNil(badValue.type) + XCTAssertNil(badValue.points) + } + + /// edge cases for `GeometryUtils::coordinates(geoJson:)` with missing keys + /// which should result in one or both return fields being `nil` + func testGeoJSONCoordinates_missing() throws { + let noType = GeometryUtils.coordinates(geoJson: """ +{"coordinates": [100.0, 0.0]} +""") + XCTAssertNil(noType.type) + XCTAssertEqual(noType.points as! [Double], [100.0, 0.0]) + + let noCoords = GeometryUtils.coordinates(geoJson: """ +{"type": "Point"} +""") + XCTAssertEqual(noCoords.type, GeometryType.point) + XCTAssertNil(noCoords.points) + } // TODO: test `geometryContainsLocation` func testExample() throws { XCTAssert(Soundscape.GeometryUtils.geometryContainsLocation(location: CLLocationCoordinate2D.init(latitude: 1, longitude: 1), coordinates: [CLLocationCoordinate2D.init(latitude: 1, longitude: 1), CLLocationCoordinate2D.init(latitude: 3, longitude: 3)])) @@ -208,21 +334,25 @@ class GeometryUtilsTest: XCTestCase { for lon in [0.0, 5.0, 10.0, 15.0, 20.0] { let on_path = CLLocationCoordinate2DMake(0, lon) let on_path_closest = GeometryUtils.closestEdge(from: on_path, on: path) - XCTAssertEqual(on_path_closest, on_path) + XCTAssertNotNil(on_path_closest) + XCTAssertEqual(on_path_closest!.coordinate, on_path) let parallel = CLLocationCoordinate2DMake(10, lon) let parallel_closest = GeometryUtils.closestEdge(from: parallel, on: path) - XCTAssertEqual(parallel_closest, on_path) + XCTAssertNotNil(parallel_closest) + XCTAssertEqual(parallel_closest!.coordinate, on_path) } for lat in [-10.0, -5.0, 0, 5.0, 10.0] { let before = CLLocationCoordinate2DMake(lat, -10) let before_closest = GeometryUtils.closestEdge(from: before, on: path) - XCTAssertEqual(before_closest, path.first) + XCTAssertNotNil(before_closest); + XCTAssertEqual(before_closest!.coordinate, path.first) let after = CLLocationCoordinate2DMake(lat, 30) let after_closest = GeometryUtils.closestEdge(from: after, on: path) - XCTAssertEqual(after_closest, path.last) + XCTAssertNotNil(after_closest) + XCTAssertEqual(after_closest!.coordinate, path.last) } } @@ -267,11 +397,13 @@ class GeometryUtilsTest: XCTestCase { let n_pole = CLLocationCoordinate2DMake(90, 0) let n_pole_closest = GeometryUtils.closestEdge(from: n_pole, on: path) - XCTAssertEqual(n_pole_closest, path.first) + XCTAssertNotNil(n_pole_closest) + XCTAssertEqual(n_pole_closest!.coordinate, path.first) let s_pole = CLLocationCoordinate2DMake(-90, 0) let s_pole_closest = GeometryUtils.closestEdge(from: s_pole, on: path) - XCTAssertEqual(s_pole_closest, path.first) + XCTAssertNotNil(s_pole_closest) + XCTAssertEqual(s_pole_closest!.coordinate, path.first) } diff --git a/apps/ios/UnitTests/Data/Models/JSON Parsing/OSM/GeoJsonFeatureTest.swift b/apps/ios/UnitTests/Data/Models/JSON Parsing/OSM/GeoJsonFeatureTest.swift deleted file mode 100644 index c6cfa397..00000000 --- a/apps/ios/UnitTests/Data/Models/JSON Parsing/OSM/GeoJsonFeatureTest.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// GeoJsonFeatureTest.swift -// UnitTests -// -// Created by Kai on 11/8/23. -// Copyright © 2023 Soundscape community. All rights reserved. -// - -import XCTest -import CoreLocation -@testable import Soundscape - - -/// Note: see ``GeoJsonGeometryTest`` if issues arise regarding parsing of the contained geometry objects. -final class GeoJsonFeatureTest: XCTestCase { - - func testParseRPI() throws { - let rpi_json = """ -{ - "feature_type": "amenity", - "feature_value": "university", - "geometry": { - "coordinates": [[[[-73.686467, 42.730566], [-73.686149, 42.732009], [-73.685002, 42.731883], [-73.683726, 42.731804], [-73.683392, 42.733057], [-73.682639, 42.732884], [-73.682458, 42.732802], [-73.681911, 42.732191], [-73.681714, 42.732282], [-73.681278, 42.733728], [-73.680441, 42.733561], [-73.679727, 42.733414], [-73.679364, 42.733336], [-73.678998, 42.733282], [-73.676712, 42.732955], [-73.676761, 42.732781], [-73.677151, 42.732838], [-73.677193, 42.732696], [-73.677726, 42.732782], [-73.677815, 42.732456], [-73.677903, 42.732135], [-73.677477, 42.731928], [-73.677807, 42.730778], [-73.677417, 42.730723], [-73.677159, 42.730717], [-73.676006, 42.73056], [-73.676017, 42.730672], [-73.675961, 42.73074], [-73.675475, 42.731016], [-73.674638, 42.730496], [-73.674799, 42.730409], [-73.67493, 42.730482], [-73.675212, 42.730647], [-73.675679, 42.730222], [-73.675501, 42.73017], [-73.675254, 42.730054], [-73.675167, 42.729998], [-73.674907, 42.729795], [-73.674852, 42.729732], [-73.674624, 42.729523], [-73.674487, 42.729452], [-73.674105, 42.729369], [-73.673404, 42.729424], [-73.672718, 42.729196], [-73.673533, 42.726224], [-73.675969, 42.726626], [-73.67629, 42.726823], [-73.67672, 42.727068], [-73.677428, 42.727328], [-73.677975, 42.727462], [-73.678137, 42.7275], [-73.678159, 42.727322], [-73.678187, 42.727021], [-73.678192, 42.727006], [-73.678198, 42.726995], [-73.678212, 42.726982], [-73.678226, 42.726972], [-73.678244, 42.726962], [-73.678264, 42.726956], [-73.678291, 42.726951], [-73.678314, 42.726951], [-73.678494, 42.726986], [-73.678777, 42.727041], [-73.679187, 42.727121], [-73.684755, 42.728203], [-73.684343, 42.729734], [-73.684201, 42.730261], [-73.686467, 42.730566]]], [[[-73.673503, 42.731682], [-73.673442, 42.731937], [-73.672991, 42.731879], [-73.672954, 42.732025], [-73.671747, 42.731875], [-73.671779, 42.731583], [-73.671382, 42.731524], [-73.671119, 42.731615], [-73.671037, 42.731946], [-73.671653, 42.732027], [-73.671589, 42.732252], [-73.67158, 42.732275], [-73.671566, 42.732285], [-73.67155, 42.73229], [-73.671534, 42.73229], [-73.671493, 42.732285], [-73.671478, 42.732296], [-73.67098, 42.732248], [-73.670851, 42.732399], [-73.670733, 42.732616], [-73.670164, 42.733215], [-73.669569, 42.733928], [-73.669268, 42.734996], [-73.669022, 42.734964], [-73.668904, 42.735413], [-73.666012, 42.734937], [-73.665894, 42.735311], [-73.66553, 42.735268], [-73.665396, 42.735792], [-73.66517, 42.735756], [-73.66414, 42.73626], [-73.664017, 42.736438], [-73.664017, 42.736627], [-73.664237, 42.737001], [-73.663373, 42.737222], [-73.66296, 42.736477], [-73.663352, 42.735524], [-73.663143, 42.735437], [-73.662665, 42.735126], [-73.662923, 42.734176], [-73.66347, 42.733928], [-73.664596, 42.73301], [-73.665857, 42.732037], [-73.666731, 42.730965], [-73.666458, 42.730792], [-73.666377, 42.73048], [-73.666517, 42.72959], [-73.667557, 42.729795], [-73.667311, 42.730445], [-73.667187, 42.730792], [-73.667729, 42.730977], [-73.668035, 42.730497], [-73.668169, 42.730287], [-73.668244, 42.73009], [-73.668233, 42.729846], [-73.668056, 42.729546], [-73.668196, 42.729341], [-73.667917, 42.7292], [-73.668078, 42.72901], [-73.667649, 42.728758], [-73.667455, 42.728411], [-73.66826, 42.728289], [-73.669166, 42.729026], [-73.669011, 42.729144], [-73.66885, 42.729286], [-73.668775, 42.72946], [-73.668775, 42.729605], [-73.668877, 42.729862], [-73.669295, 42.730476], [-73.670583, 42.730449], [-73.672042, 42.730641], [-73.671792, 42.731521], [-73.672944, 42.731702], [-73.672965, 42.731612], [-73.673503, 42.731682]]], [[[-73.663248, 42.732892], [-73.663118, 42.733223], [-73.662257, 42.733463], [-73.661554, 42.735411], [-73.66107, 42.735223], [-73.660714, 42.734099], [-73.660986, 42.733317], [-73.661919, 42.732265], [-73.663121, 42.732549], [-73.663248, 42.732892]]], [[[-73.673902, 42.73513], [-73.673492, 42.736589], [-73.67202, 42.736366], [-73.67243, 42.734907], [-73.673902, 42.73513]]], [[[-73.670324, 42.736868], [-73.670168, 42.737384], [-73.668723, 42.737149], [-73.668801, 42.73689], [-73.668548, 42.736848], [-73.668626, 42.736591], [-73.670324, 42.736868]]]], - "type": "MultiPolygon" - }, - "osm_ids": [-100000000008670722], - "properties": { - "addr:city": "Troy", - "addr:flats": "209;4213", - "addr:housenumber": "110", - "addr:postcode": "12180", - "addr:state": "NY", - "addr:street": "8th Street", - "amenity": "university", - "name": "Rensselaer Polytechnic Institute", - "nysgissam:nysaddresspointid": "RENS081205;RENS045006;RENS080924", - "smoking": "no", - "type": "multipolygon", - "website": "https://rpi.edu", - "wikidata": "Q49211", - "wikipedia": "en:Rensselaer Polytechnic Institute" - }, - "type": "Feature" -} -""".data(using: .utf8)! - - let rpi_feature = try JSONDecoder().decode(GeoJsonFeature.self, from: rpi_json) - // Since it's defined in a string, changes to OSM shouldn't affect this test - - XCTAssertEqual(rpi_feature.name, "Rensselaer Polytechnic Institute") - XCTAssertEqual(rpi_feature.type, "amenity") - XCTAssertEqual(rpi_feature.value, "university") - XCTAssertEqual(rpi_feature.osmIds, ["ft-100000000008670722"]) - XCTAssertEqual(rpi_feature.geometry.rawValue, "MultiPolygon") - //XCTAssertEqual(rpi_feature.superCategory, .undefined) - - XCTAssertEqual(rpi_feature.properties, [ - "addr:city": "Troy", - "addr:flats": "209;4213", - "addr:housenumber": "110", - "addr:postcode": "12180", - "addr:state": "NY", - "addr:street": "8th Street", - "amenity": "university", - "name": "Rensselaer Polytechnic Institute", - "nysgissam:nysaddresspointid": "RENS081205;RENS045006;RENS080924", - "smoking": "no", - "type": "multipolygon", - "website": "https://rpi.edu", - "wikidata": "Q49211", - "wikipedia": "en:Rensselaer Polytechnic Institute" - ]) - - // Is not a road - XCTAssertFalse(rpi_feature.isCrossing) // that would make no sense - XCTAssertFalse(rpi_feature.isRoundabout) // this too - XCTAssertNil(rpi_feature.ref) - XCTAssertNil(rpi_feature.nameTag) - } - - func testParseSageAvenue() throws { - let sage_json = """ -{ - "feature_type": "highway", - "feature_value": "tertiary", - "geometry": { - "coordinates": [[-73.677224, 42.730786], [-73.677061, 42.730764], [-73.676573, 42.730701], [-73.676491, 42.73068], [-73.676317, 42.730619], [-73.676147, 42.730521], [-73.675929, 42.730405]], - "type": "LineString" - }, - "osm_ids": [-669453514], - "properties": { - "highway": "tertiary", - "maxspeed": "30 mph", - "name": "Sage Avenue", - "surface": "asphalt" - }, - "type": "Feature" -} -""".data(using: .utf8)! - - let sage_feature = try JSONDecoder().decode(GeoJsonFeature.self, from: sage_json) - // Since it's defined in a string, changes to OSM shouldn't affect this test - - XCTAssertEqual(sage_feature.name, "Sage Avenue") - XCTAssertEqual(sage_feature.type, "highway") - XCTAssertEqual(sage_feature.value, "tertiary") - XCTAssertEqual(sage_feature.osmIds, ["ft-669453514"]) - XCTAssertEqual(sage_feature.geometry.rawValue, "LineString") - - XCTAssertEqual(sage_feature.properties, [ - "highway": "tertiary", - "maxspeed": "30 mph", - "name": "Sage Avenue", - "surface": "asphalt" - ]) - - // these are mostly determined by us, not a part of GeoJson spec - XCTAssertEqual(sage_feature.superCategory, .roads) - XCTAssertFalse(sage_feature.isCrossing) - XCTAssertFalse(sage_feature.isRoundabout) - XCTAssertNil(sage_feature.ref) - XCTAssertEqual(sage_feature.nameTag, "road") - } - - func testParseEmpty() throws { - let data_empty_string = "".data(using: .utf8)! - XCTAssertThrowsError(try JSONDecoder().decode(GeoJsonFeature.self, from: data_empty_string)) - } - -} diff --git a/apps/ios/UnitTests/Data/Models/JSON Parsing/OSM/GeoJsonGeometryTest.swift b/apps/ios/UnitTests/Data/Models/JSON Parsing/OSM/GeoJsonGeometryTest.swift deleted file mode 100644 index 17e48907..00000000 --- a/apps/ios/UnitTests/Data/Models/JSON Parsing/OSM/GeoJsonGeometryTest.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// GeoJsonGeometryTest.swift -// -// -// Created by Kai on 11/7/23. -// - -import XCTest -import CoreLocation -@testable import Soundscape - -final class GeoJsonGeometryTest: XCTestCase { - - // GeoJSON strings taken/adapted from the GeoJSON spec, RFC-7946 - - /// normal test case for `GeoJsonGeometry.init(geoJSON: String)` - func testParseGeoJsonGeometry_Point() throws { - /// `Point`-- coordinates are a `[Double]` - let point = GeoJsonGeometry(geoJSON: """ -{ - "type": "Point", - "coordinates": [100.0, 0.0] -} -""") - - XCTAssertEqual(point, .point(coordinates: CLLocationCoordinate2DMake(0, 100))) - } - - /// normal test case for `GeoJsonGeometry.init(geoJSON: String)` - func testParseGeoJsonGeometry_LineString() throws { - /// `LineString`-- coordinates are a `[[Double]]` - let lineString = GeoJsonGeometry(geoJSON: """ -{ - "type": "LineString", - "coordinates": [ - [100.0, 0.0], - [101.0, 1.0] - ] -} -""") - XCTAssertEqual(lineString, .lineString(coordinates: [CLLocationCoordinate2DMake(0, 100), - CLLocationCoordinate2DMake(1, 101)])) - } - /// normal test case for `GeoJsonGeometry.init(geoJSON: String)` - func testParseGeoJsonGeometry_Polygon() throws { - /// `Polygon`-- coordinates are a `[[[Double]]]` - let poly = GeoJsonGeometry(geoJSON: """ -{ - "type": "Polygon", - "coordinates": [ - [ - [100.0, 0.0], - [101.0, 0.0], - [101.0, 1.0], - [100.0, 1.0], - [100.0, 0.0] - ], - [ - [100.8, 0.8], - [100.8, 0.2], - [100.2, 0.2], - [100.2, 0.8], - [100.8, 0.8] - ] - ] -} -""") - XCTAssertEqual(poly, .polygon(coordinates: [ - [ - CLLocationCoordinate2DMake(0, 100), - CLLocationCoordinate2DMake(0, 101), - CLLocationCoordinate2DMake(1, 101), - CLLocationCoordinate2DMake(1, 100), - CLLocationCoordinate2DMake(0, 100) - ], - [ - CLLocationCoordinate2DMake(0.8, 100.8), - CLLocationCoordinate2DMake(0.2, 100.8), - CLLocationCoordinate2DMake(0.2, 100.2), - CLLocationCoordinate2DMake(0.8, 100.2), - CLLocationCoordinate2DMake(0.8, 100.8) - ] - ])) - } - - func testParseGeoJsonGeometry_invalidType() throws { - let a = GeoJsonGeometry(geoJSON: """ -{ - "type": "a", - "coordinates": [100.0, 0.0] -} -""") - XCTAssertNil(a) - } - - /// edge case for `GeoJsonGeometry.init(geoJSON: String)` with empty input - /// which should result in `(nil, nil)` - func testParseGeoJsonGeometry_emptystring() throws { - XCTAssertNil(GeoJsonGeometry(geoJSON: "")) - } - - /// edge cases for `GeoJsonGeometry.init(geoJSON: String)` with malformed json - func testParseGeoJsonGeometry_malformed() throws { - XCTAssertNil(GeoJsonGeometry(geoJSON: "{a: 1}")) - XCTAssertNil(GeoJsonGeometry(geoJSON: "{\"a\": asdf}")) - } - - /// edge cases for `GeoJsonGeometry.init(geoJSON: String)` with missing keys - /// which should result in `nil` - func testParseGeoJsonGeometry_missing() throws { - let noType = GeoJsonGeometry(geoJSON: """ -{"coordinates": [100.0, 0.0]} -""") - XCTAssertNil(noType) - - let noCoords = GeoJsonGeometry(geoJSON: """ -{"type": "Point"} -""") - XCTAssertNil(noCoords) - } - -} diff --git a/apps/ios/UnitTests/Data/Services/OSM/OSMServiceModelTest.swift b/apps/ios/UnitTests/Data/Services/OSM/OSMServiceModelTest.swift deleted file mode 100644 index 4e4cacfa..00000000 --- a/apps/ios/UnitTests/Data/Services/OSM/OSMServiceModelTest.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// OSMServiceModelTest.swift -// -// -// Created by Kai on 9/29/23. -// - -import XCTest -import CoreLocation -@testable import Soundscape - -final class OSMServiceModelTest: XCTestCase { - let osm = OSMServiceModel() - let tile0_0 = VectorTile(latitude: 0, longitude: 0, zoom: 16) - let tileRPI = VectorTile(latitude: 42.73036, longitude: -73.67663, zoom: 16) - - /// Tests a point in the middle of the ocean, which should be empty - func testGetTileData_Empty() throws { - let expectation = XCTestExpectation() - - // I think [:] means all categories - osm.getTileData(tile: tile0_0, categories: [:]) {status,tiledata,err in - XCTAssertNil(err) - XCTAssertEqual(status, .success) - XCTAssertNotNil(tiledata) - - guard let tiledata = tiledata else { - XCTFail("tiledata was nil") - expectation.fulfill() - return - } - // (0, 0) is in the Atlantic Ocean - // There should be nothing here - XCTAssertTrue(tiledata.pois.isEmpty) - XCTAssertTrue(tiledata.roads.isEmpty) - XCTAssertTrue(tiledata.paths.isEmpty) - XCTAssertTrue(tiledata.intersections.isEmpty) - // But we should have generated metadata - XCTAssertFalse(tiledata.etag.isEmpty) - XCTAssertFalse(tiledata.quadkey.isEmpty) - XCTAssertGreaterThan(tiledata.ttl.addingTimeInterval(-10), Date(timeIntervalSinceNow: 0)) // it should live past now - expectation.fulfill() - } - - XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 10), .completed, "OSM getTileData timed out") - } - - /// Tests the tile containing Rensselaer Polytechnic Institute - func testGetTileData_RPI() throws { - let expectation = XCTestExpectation() - - // I think [:] means all categories - osm.getTileData(tile: tileRPI, categories: [:]) {status,tiledata,err in - XCTAssertNil(err) - XCTAssertEqual(status, .success) - - guard let tiledata = tiledata else { - XCTFail("tiledata was nil") - expectation.fulfill() - return - } - // RPI is a busy place with lots of stuff - // There should be a lot of data - XCTAssertFalse(tiledata.pois.isEmpty) - XCTAssertFalse(tiledata.roads.isEmpty) - XCTAssertFalse(tiledata.paths.isEmpty) - XCTAssertFalse(tiledata.intersections.isEmpty) - // We should have generated metadata - XCTAssertFalse(tiledata.etag.isEmpty) - XCTAssertFalse(tiledata.quadkey.isEmpty) - XCTAssertGreaterThan(tiledata.ttl.addingTimeInterval(-10), Date(timeIntervalSinceNow: 0)) // cache should live longer than just right now - - // RPI should be in here - guard let RPI = tiledata.pois.first(where: {$0.name == "Rensselaer Polytechnic Institute"}) else { - // assuming RPI will still exist - XCTFail("could not find RPI in its tile") - expectation.fulfill() - return - } - XCTAssertEqual(RPI.amenity, "university") - //XCTAssertEqual(RPI.geometryType, .multiPolygon) - //XCTAssertEqual(RPI.dynamicURL, "https://rpi.edu") - XCTAssertEqual(RPI.streetName, "8th Street") - XCTAssertEqual(RPI.addressLine, "110 8th Street") - let geometry = RPI.geometry - XCTAssertNotNil(geometry) - if case .multiPolygon(let coordinates) = geometry { - XCTAssertFalse(coordinates.isEmpty) - } else { - XCTFail("RPI geometry should be a multiPolygon") - } - // Ensure RPI is roughly where it should be (with error since the exact location may shift as properties change over time) - XCTAssertEqual(RPI.centroidLatitude, 42.73036, accuracy: 0.05) - XCTAssertEqual(RPI.centroidLongitude, -73.67663, accuracy: 0.05) - - - - - // get by id since there are multiple segments of Sage Avenue - guard let sage_ave = tiledata.roads.first(where: {$0.key == "ft-282843345"}) else { - // assuming Sage ave. will still exist - XCTFail("could not find Sage Avenue in its tile") - expectation.fulfill() - return - } - XCTAssertEqual(sage_ave.name, "Sage Avenue") - XCTAssertEqual(sage_ave.type, "road") - // XCTAssertEqual(sage_ave.geometryType, .lineString) - XCTAssertNil(sage_ave.streetName) // Streets are at themselves, so have no address - XCTAssertNil(sage_ave.addressLine) - XCTAssertNil(sage_ave.phone) // Streets don't have phone numbers - XCTAssertFalse(sage_ave.roundabout) // unless they've done work since now - - expectation.fulfill() - } - - XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 10), .completed, "OSM getTileData timed out") - } - -}