diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme index 2629d5f..e796e9a 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme @@ -202,6 +202,20 @@ ReferencedContainer = "container:"> </BuildableReference> </BuildActionEntry> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "GeoJSON" + BuildableName = "GeoJSON" + BlueprintName = "GeoJSON" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> </BuildActionEntries> </BuildAction> <TestAction @@ -310,6 +324,16 @@ ReferencedContainer = "container:"> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "GeoJSONTests" + BuildableName = "GeoJSONTests" + BlueprintName = "GeoJSONTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> </Testables> </TestAction> <LaunchAction diff --git a/Package.swift b/Package.swift index 359ba0e..195615d 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "WGS84", targets: ["WGS84"]), .library(name: "GeodeticGeometry", targets: ["GeodeticGeometry"]), .library(name: "Turf", targets: ["Turf"]), + .library(name: "GeoJSON", targets: ["GeoJSON"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-algorithms", .upToNextMajor(from: "1.0.0")), @@ -137,5 +138,19 @@ let package = Package( "GeodeticConversions", "WGS84Conversions", ]), + + // 📄 GeoJSON representation + .target( + name: "GeoJSON", + dependencies: [ + .target(name: "GeodeticGeometry"), + .target(name: "WGS84Turf"), + .product(name: "NonEmpty", package: "swift-nonempty"), + ] + ), + .testTarget( + name: "GeoJSONTests", + dependencies: ["GeoJSON"] + ), ] ) diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift new file mode 100644 index 0000000..027ec10 --- /dev/null +++ b/Sources/GeoJSON/BoundingBox.swift @@ -0,0 +1,61 @@ +// +// BoundingBox.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoModels + +/// A [GeoJSON Bounding Box](https://datatracker.ietf.org/doc/html/rfc7946#section-5). +public protocol BoundingBox: GeoModels.BoundingBox, Codable { + + /// This bonding box, but type-erased. + var asAny: AnyBoundingBox { get } + +} + +/// A two-dimensional ``BoundingBox``. +public typealias BoundingBox2D = GeoModels.BoundingBox2D + +extension BoundingBox2D: BoundingBox { + + public var asAny: AnyBoundingBox { .twoDimensions(self) } + +} + +/// A three-dimensional ``BoundingBox``. +public typealias BoundingBox3D = GeoModels.BoundingBox3D + +extension BoundingBox3D: BoundingBox { + + public var asAny: AnyBoundingBox { .threeDimensions(self) } + +} + +/// A type-erased ``BoundingBox``. +public enum AnyBoundingBox: BoundingBox, Hashable, Codable { + + public static var zero: AnyBoundingBox = .twoDimensions(.zero) + + public func union(_ other: AnyBoundingBox) -> AnyBoundingBox { + switch (self, other) { + case let (.twoDimensions(self), .twoDimensions(other)): + return .twoDimensions(self.union(other)) + + case let (.twoDimensions(bbox2d), .threeDimensions(bbox3d)), + let (.threeDimensions(bbox3d), .twoDimensions(bbox2d)): + return .threeDimensions(bbox3d.union(bbox2d)) + + case let (.threeDimensions(self), .threeDimensions(other)): + return .threeDimensions(self.union(other)) + } + } + + case twoDimensions(BoundingBox2D) + case threeDimensions(BoundingBox3D) + + public var asAny: AnyBoundingBox { self } + +} diff --git a/Sources/GeoJSON/Errors.swift b/Sources/GeoJSON/Errors.swift new file mode 100644 index 0000000..8b240b5 --- /dev/null +++ b/Sources/GeoJSON/Errors.swift @@ -0,0 +1,17 @@ +// +// Errors.swift +// SwiftGeo +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import Foundation + +/// Error when creating ``LinearRingCoordinates``. +/// +/// See [RFC 7946, section 3.1.6](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6). +public enum LinearRingError: Error { + case firstAndLastPositionsShouldBeEquivalent + case notEnoughPoints +} diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift new file mode 100644 index 0000000..70059ec --- /dev/null +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -0,0 +1,484 @@ +// +// GeoJSON+Codable.swift +// SwiftGeo +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import Foundation +import GeoModels + +//extension BinaryFloatingPoint { +// +// /// Rounds the double to decimal places value +// fileprivate func roundedToPlaces(_ places: Int) -> Self { +// let divisor = pow(10.0, Double(places)) +// return Self((Double(self) * divisor).rounded() / divisor) +// } +// +//} + +extension Double { + + /// Rounds the double to decimal places value + func roundedToPlaces(_ places: Int) -> Decimal { + let divisor = pow(10.0, Double(places)) + return Decimal((self * divisor).rounded()) / Decimal(divisor) + } + +} + +extension Latitude: Codable { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + let decimalDegrees = try container.decode(Double.self) + + self.init(decimalDegrees: decimalDegrees) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.decimalDegrees.roundedToPlaces(6)) + } + +} + +extension Longitude: Codable { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + let decimalDegrees = try container.decode(Double.self) + + self.init(decimalDegrees: decimalDegrees) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.decimalDegrees.roundedToPlaces(6)) + } + +} + +extension Altitude: Codable { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + let meters = try container.decode(Double.self) + + self.init(meters: meters) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.meters.roundedToPlaces(3)) + } + +} + +extension Position2D: Codable { + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let longitude = try container.decode(Longitude.self) + let latitude = try container.decode(Latitude.self) + + self.init(latitude: latitude, longitude: longitude) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + try container.encode(self.longitude) + try container.encode(self.latitude) + } + +} + +extension Position3D: Codable { + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let longitude = try container.decode(Longitude.self) + let latitude = try container.decode(Latitude.self) + let altitude = try container.decode(Altitude.self) + + self.init(latitude: latitude, longitude: longitude, altitude: altitude) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + try container.encode(self.longitude) + try container.encode(self.latitude) + try container.encode(self.altitude) + } + +} + +extension LinearRingCoordinates { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + let coordinates = try container.decode(Self.RawValue.self) + + try self.init(rawValue: coordinates) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.rawValue) + } + +} + +fileprivate enum SingleGeometryCodingKeys: String, CodingKey { + case geoJSONType = "type" + case coordinates, bbox +} + +extension SingleGeometry { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SingleGeometryCodingKeys.self) + + let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType) + guard type == Self.geoJSONType else { + throw DecodingError.typeMismatch(Self.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Found GeoJSON type '\(type.rawValue)'" + )) + } + + let coordinates = try container.decode(Self.Coordinates.self, forKey: .coordinates) + + self.init(coordinates: coordinates) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: SingleGeometryCodingKeys.self) + + try container.encode(Self.geoJSONType, forKey: .geoJSONType) + try container.encode(self.coordinates, forKey: .coordinates) + try container.encode(self.bbox, forKey: .bbox) + } + +} + +fileprivate enum GeometryCollectionCodingKeys: String, CodingKey { + case geoJSONType = "type" + case geometries, bbox +} + +extension GeometryCollection { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: GeometryCollectionCodingKeys.self) + + let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType) + guard type == Self.geoJSONType else { + throw DecodingError.typeMismatch(Self.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Found GeoJSON type '\(type.rawValue)'" + )) + } + + let geometries = try container.decode([AnyGeometry].self, forKey: .geometries) + + self.init(geometries: geometries) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: GeometryCollectionCodingKeys.self) + + try container.encode(Self.geoJSONType, forKey: .geoJSONType) + try container.encode(self.geometries, forKey: .geometries) + try container.encode(self.bbox, forKey: .bbox) + } + +} + +fileprivate enum AnyGeometryCodingKeys: String, CodingKey { + case geoJSONType = "type" +} + +extension AnyGeometry { + + public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: SingleGeometryCodingKeys.self) + let type = try typeContainer.decode(GeoJSON.`Type`.Geometry.self, forKey: .geoJSONType) + + let container = try decoder.singleValueContainer() + + // TODO: Fix 2D/3D performance by checking the number of values in `bbox` + switch type { + case .geometryCollection: + let geometryCollection = try container.decode(GeometryCollection.self) + self = .geometryCollection(geometryCollection) + case .point: + do { + let point3D = try container.decode(Point3D.self) + self = .point3D(point3D) + } catch { + let point2D = try container.decode(Point2D.self) + self = .point2D(point2D) + } + case .multiPoint: + do { + let multiPoint3D = try container.decode(MultiPoint3D.self) + self = .multiPoint3D(multiPoint3D) + } catch { + let multiPoint2D = try container.decode(MultiPoint2D.self) + self = .multiPoint2D(multiPoint2D) + } + case .lineString: + do { + let lineString3D = try container.decode(LineString3D.self) + self = .lineString3D(lineString3D) + } catch { + let lineString2D = try container.decode(LineString2D.self) + self = .lineString2D(lineString2D) + } + case .multiLineString: + do { + let multiLineString3D = try container.decode(MultiLineString3D.self) + self = .multiLineString3D(multiLineString3D) + } catch { + let multiLineString2D = try container.decode(MultiLineString2D.self) + self = .multiLineString2D(multiLineString2D) + } + case .polygon: + do { + let polygon3D = try container.decode(Polygon3D.self) + self = .polygon3D(polygon3D) + } catch { + let polygon2D = try container.decode(Polygon2D.self) + self = .polygon2D(polygon2D) + } + case .multiPolygon: + do { + let multiPolygon3D = try container.decode(MultiPolygon3D.self) + self = .multiPolygon3D(multiPolygon3D) + } catch { + let multiPolygon2D = try container.decode(MultiPolygon2D.self) + self = .multiPolygon2D(multiPolygon2D) + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .geometryCollection(let geometryCollection): + try container.encode(geometryCollection) + + case .point2D(let point2D): + try container.encode(point2D) + case .multiPoint2D(let multiPoint2D): + try container.encode(multiPoint2D) + case .lineString2D(let lineString2D): + try container.encode(lineString2D) + case .multiLineString2D(let multiLineString2D): + try container.encode(multiLineString2D) + case .polygon2D(let polygon2D): + try container.encode(polygon2D) + case .multiPolygon2D(let multiPolygon2D): + try container.encode(multiPolygon2D) + + case .point3D(let point3D): + try container.encode(point3D) + case .multiPoint3D(let multiPoint3D): + try container.encode(multiPoint3D) + case .lineString3D(let lineString3D): + try container.encode(lineString3D) + case .multiLineString3D(let multiLineString3D): + try container.encode(multiLineString3D) + case .polygon3D(let polygon3D): + try container.encode(polygon3D) + case .multiPolygon3D(let multiPolygon3D): + try container.encode(multiPolygon3D) + } + } + +} + +extension BoundingBox2D { + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let westLongitude = try container.decode(Longitude.self) + let southLatitude = try container.decode(Latitude.self) + let eastLongitude = try container.decode(Longitude.self) + let northLatitude = try container.decode(Latitude.self) + + self.init( + southWest: Coordinate2D(latitude: southLatitude, longitude: westLongitude), + northEast: Coordinate2D(latitude: northLatitude, longitude: eastLongitude) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + try container.encode(self.westLongitude) + try container.encode(self.southLatitude) + try container.encode(self.eastLongitude) + try container.encode(self.northLatitude) + } + +} + +extension BoundingBox3D { + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let westLongitude = try container.decode(Longitude.self) + let southLatitude = try container.decode(Latitude.self) + let lowAltitude = try container.decode(Altitude.self) + let eastLongitude = try container.decode(Longitude.self) + let northLatitude = try container.decode(Latitude.self) + let highAltitude = try container.decode(Altitude.self) + + self.init( + southWestLow: Coordinate3D( + latitude: southLatitude, + longitude: westLongitude, + altitude: lowAltitude + ), + northEastHigh: Coordinate3D( + latitude: northLatitude, + longitude: eastLongitude, + altitude: highAltitude + ) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + try container.encode(self.twoDimensions.westLongitude) + try container.encode(self.twoDimensions.southLatitude) + try container.encode(self.lowAltitude) + try container.encode(self.twoDimensions.eastLongitude) + try container.encode(self.twoDimensions.northLatitude) + try container.encode(self.highAltitude) + } + +} + +extension AnyBoundingBox { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + do { + let boundingBox3D = try container.decode(BoundingBox3D.self) + self = .threeDimensions(boundingBox3D) + } catch { + let boundingBox2D = try container.decode(BoundingBox2D.self) + self = .twoDimensions(boundingBox2D) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .twoDimensions(let boundingBox2D): + try container.encode(boundingBox2D) + case .threeDimensions(let boundingBox3D): + try container.encode(boundingBox3D) + } + } + +} + +fileprivate enum FeatureCodingKeys: String, CodingKey { + case geoJSONType = "type" + case id, geometry, properties, bbox +} + +extension Feature { + + public init(from decoder: Decoder) throws { + print(String(describing: ID.self)) + let container = try decoder.container(keyedBy: FeatureCodingKeys.self) + + let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType) + guard type == Self.geoJSONType else { + throw DecodingError.typeMismatch(Self.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Found GeoJSON type '\(type.rawValue)'" + )) + } + + let id = try container.decodeIfPresent(ID.self, forKey: .id) + let geometry = try container.decodeIfPresent(Geometry.self, forKey: .geometry) + let properties = try container.decode(Properties.self, forKey: .properties) + + self.init(id: id, geometry: geometry, properties: properties) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: FeatureCodingKeys.self) + + try container.encode(Self.geoJSONType, forKey: .geoJSONType) + try container.encodeIfPresent(self.id, forKey: .id) + try container.encodeIfPresent(self.geometry, forKey: .geometry) + try container.encode(self.properties, forKey: .properties) + // TODO: Create GeoJSONEncoder that allows setting "export bboxes" to a boolean value + try container.encodeIfPresent(self.bbox, forKey: .bbox) + } + +} + +fileprivate enum FeatureCollectionCodingKeys: String, CodingKey { + case geoJSONType = "type" + case features, bbox +} + +extension FeatureCollection { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: FeatureCollectionCodingKeys.self) + + let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType) + guard type == Self.geoJSONType else { + throw DecodingError.typeMismatch(Self.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Found GeoJSON type '\(type.rawValue)'" + )) + } + + let features = try container.decodeIfPresent( + [Feature<ID, Geometry, Properties>].self, + forKey: .features + ) ?? [] + + self.init(features: features) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: FeatureCollectionCodingKeys.self) + + try container.encode(Self.geoJSONType, forKey: .geoJSONType) + try container.encodeIfPresent(self.features, forKey: .features) + // TODO: Create GeoJSONEncoder that allows setting "export bboxes" to a boolean value + try container.encodeIfPresent(self.bbox, forKey: .bbox) + } + +} diff --git a/Sources/GeoJSON/GeoJSON.docc/Documentation.md b/Sources/GeoJSON/GeoJSON.docc/Documentation.md new file mode 100644 index 0000000..769ce58 --- /dev/null +++ b/Sources/GeoJSON/GeoJSON.docc/Documentation.md @@ -0,0 +1,30 @@ +# ``GeoJSON`` + +## Topics + +### Positions + +- ``Position`` +- ``Position2D`` +- ``Position3D`` + +### Types + +- <doc:Types> + +### Objects + +- ``Object`` +- ``CodableObject`` +- <doc:Geometries> +- ``Feature`` +- ``AnyFeature`` +- ``FeatureCollection`` +- ``AnyFeatureCollection`` + +### Bounding Boxes + +- ``BoundingBox`` +- ``BoundingBox2D`` +- ``BoundingBox3D`` +- ``AnyBoundingBox`` diff --git a/Sources/GeoJSON/GeoJSON.docc/Geometries.md b/Sources/GeoJSON/GeoJSON.docc/Geometries.md new file mode 100644 index 0000000..ec387f0 --- /dev/null +++ b/Sources/GeoJSON/GeoJSON.docc/Geometries.md @@ -0,0 +1,55 @@ +# Geometries + +## Topics + +### Base + +- ``Geometry`` +- ``CodableGeometry`` +- ``AnyGeometry`` + +### Introduced concepts + +- ``SingleGeometry`` +- ``LinearRingCoordinates`` +- ``LinearRingError`` + +### Point + +- ``Point`` +- ``Point2D`` +- ``Point3D`` + +### MultiPoint + +- ``MultiPoint`` +- ``MultiPoint2D`` +- ``MultiPoint3D`` + +### LineString + +- ``LineString`` +- ``LineString2D`` +- ``LineString3D`` + +### MultiLineString + +- ``MultiLineString`` +- ``MultiLineString2D`` +- ``MultiLineString3D`` + +### Polygon + +- ``Polygon`` +- ``Polygon2D`` +- ``Polygon3D`` + +### MultiPolygon + +- ``MultiPolygon`` +- ``MultiPolygon2D`` +- ``MultiPolygon3D`` + +### GeometryCollection + +- ``GeometryCollection`` diff --git a/Sources/GeoJSON/GeoJSON.docc/Types.md b/Sources/GeoJSON/GeoJSON.docc/Types.md new file mode 100644 index 0000000..4573a73 --- /dev/null +++ b/Sources/GeoJSON/GeoJSON.docc/Types.md @@ -0,0 +1,20 @@ +# Types + +## Topics + +### Enums + +- ``Type`` +- ``Type/Geometry`` + +### Cases + +- ``Type/Geometry/point`` +- ``Type/Geometry/multiPoint`` +- ``Type/Geometry/lineString`` +- ``Type/Geometry/multiLineString`` +- ``Type/Geometry/polygon`` +- ``Type/Geometry/multiPolygon`` +- ``Type/Geometry/geometryCollection`` +- ``Type/feature`` +- ``Type/featureCollection`` diff --git a/Sources/GeoJSON/Geometries/GeometryCollection.swift b/Sources/GeoJSON/Geometries/GeometryCollection.swift new file mode 100644 index 0000000..cc1fdc7 --- /dev/null +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -0,0 +1,24 @@ +// +// GeometryCollection.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON GeometryCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8). +public struct GeometryCollection: CodableGeometry { + + public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection } + + public var _bbox: AnyBoundingBox { geometries.bbox } + + public var asAnyGeometry: AnyGeometry { .geometryCollection(self) } + + public var geometries: [AnyGeometry] + + public init(geometries: [AnyGeometry]) { + self.geometries = geometries + } + +} diff --git a/Sources/GeoJSON/Geometries/LineString.swift b/Sources/GeoJSON/Geometries/LineString.swift new file mode 100644 index 0000000..1659814 --- /dev/null +++ b/Sources/GeoJSON/Geometries/LineString.swift @@ -0,0 +1,69 @@ +// +// LineString.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty + +/// A [GeoJSON LineString](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4). +public protocol LineString: SingleGeometry { + + associatedtype Position: GeoJSON.Position + associatedtype Coordinates = NonEmpty<NonEmpty<[Position]>> + +} + +extension LineString { + + public static var geometryType: GeoJSON.`Type`.Geometry { .lineString } + +} + +/// A ``LineString`` with ``Point2D``s. +public struct LineString2D: LineString { + + public typealias Position = Position2D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .lineString2D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + + public init?(coordinates: [Position2D]) { + guard let coordinates = NonEmpty(rawValue: coordinates) + .flatMap(NonEmpty.init(rawValue:)) + else { return nil } + + self.init(coordinates: coordinates) + } + +} + +/// A ``LineString`` with ``Point3D``s. +public struct LineString3D: LineString { + + public typealias Position = Position3D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .lineString3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + + public init?(coordinates: [Position3D]) { + guard let coordinates = NonEmpty(rawValue: coordinates) + .flatMap(NonEmpty.init(rawValue:)) + else { return nil } + + self.init(coordinates: coordinates) + } + +} diff --git a/Sources/GeoJSON/Geometries/MultiLineString.swift b/Sources/GeoJSON/Geometries/MultiLineString.swift new file mode 100644 index 0000000..1eb6f8c --- /dev/null +++ b/Sources/GeoJSON/Geometries/MultiLineString.swift @@ -0,0 +1,81 @@ +// +// MultiLineString.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty + +/// A [GeoJSON MultiLineString](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.5). +public protocol MultiLineString: SingleGeometry { + + associatedtype LineString: GeoJSON.LineString + associatedtype Coordinates = [LineString.Coordinates] + +} + +extension MultiLineString { + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiLineString } + +} + +/// A ``MultiLineString`` with ``Point2D``s. +public struct MultiLineString2D: MultiLineString { + + public typealias LineString = LineString2D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiLineString2D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + + public init?(coordinates: [[Position2D]]) { + var coord1 = [LineString2D.Coordinates]() + + for coord2 in coordinates { + guard let coord3 = NonEmpty(rawValue: coord2) + .flatMap(NonEmpty.init(rawValue:)) + else { return nil } + + coord1.append(coord3) + } + + self.init(coordinates: coord1) + } + +} + +/// A ``MultiLineString`` with ``Point3D``s. +public struct MultiLineString3D: MultiLineString { + + public typealias LineString = LineString3D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiLineString3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + + public init?(coordinates: [[Position3D]]) { + var coord1 = [LineString3D.Coordinates]() + + for coord2 in coordinates { + guard let coord3 = NonEmpty(rawValue: coord2) + .flatMap(NonEmpty.init(rawValue:)) + else { return nil } + + coord1.append(coord3) + } + + self.init(coordinates: coord1) + } + +} diff --git a/Sources/GeoJSON/Geometries/MultiPoint.swift b/Sources/GeoJSON/Geometries/MultiPoint.swift new file mode 100644 index 0000000..381c87c --- /dev/null +++ b/Sources/GeoJSON/Geometries/MultiPoint.swift @@ -0,0 +1,51 @@ +// +// MultiPoint.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON MultiPoint](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.3). +public protocol MultiPoint: SingleGeometry { + + associatedtype Point: GeoJSON.Point + associatedtype Coordinates = [Point.Coordinates] + +} + +extension MultiPoint { + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPoint } + +} + +/// A ``MultiPoint`` with ``Point2D``s. +public struct MultiPoint2D: MultiPoint { + + public typealias Point = Point2D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiPoint2D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} + +/// A ``MultiPoint`` with ``Point3D``s. +public struct MultiPoint3D: MultiPoint { + + public typealias Point = Point3D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiPoint3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} diff --git a/Sources/GeoJSON/Geometries/MultiPolygon.swift b/Sources/GeoJSON/Geometries/MultiPolygon.swift new file mode 100644 index 0000000..bb477d2 --- /dev/null +++ b/Sources/GeoJSON/Geometries/MultiPolygon.swift @@ -0,0 +1,51 @@ +// +// MultiPolygon.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON MultiPolygon](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.7). +public protocol MultiPolygon: SingleGeometry { + + associatedtype Polygon: GeoJSON.Polygon + associatedtype Coordinates = [Polygon.Coordinates] + +} + +extension MultiPolygon { + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPolygon } + +} + +/// A ``MultiPolygon`` with ``Point2D``s. +public struct MultiPolygon2D: MultiPolygon { + + public typealias Polygon = Polygon2D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiPolygon2D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} + +/// A ``MultiPolygon`` with ``Point3D``s. +public struct MultiPolygon3D: MultiPolygon { + + public typealias Polygon = Polygon3D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiPolygon3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} diff --git a/Sources/GeoJSON/Geometries/Point.swift b/Sources/GeoJSON/Geometries/Point.swift new file mode 100644 index 0000000..968d013 --- /dev/null +++ b/Sources/GeoJSON/Geometries/Point.swift @@ -0,0 +1,51 @@ +// +// Point.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON Point](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.2). +public protocol Point: SingleGeometry { + + associatedtype Position: GeoJSON.Position + associatedtype Coordinates = Position + +} + +extension Point { + + public static var geometryType: GeoJSON.`Type`.Geometry { .point } + +} + +/// A two-dimensional ``Point`` (with longitude and latitude). +public struct Point2D: Point { + + public typealias Position = Position2D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .point2D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} + +/// A three-dimensional ``Point`` (with longitude, latitude and altitude). +public struct Point3D: Point { + + public typealias Position = Position3D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .point3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} diff --git a/Sources/GeoJSON/Geometries/Polygon.swift b/Sources/GeoJSON/Geometries/Polygon.swift new file mode 100644 index 0000000..c807cd8 --- /dev/null +++ b/Sources/GeoJSON/Geometries/Polygon.swift @@ -0,0 +1,93 @@ +// +// Polygon.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty +import Turf + +/// A [GeoJSON Polygon](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6) / linear ring. +public protocol Polygon: SingleGeometry { + + associatedtype Point: GeoJSON.Point + associatedtype Coordinates = [LinearRingCoordinates<Point>] + +} + +extension Polygon { + + public static var geometryType: GeoJSON.`Type`.Geometry { .polygon } + +} + +public struct LinearRingCoordinates<Point: GeoJSON.Point>: Boundable, Hashable, Codable { + + public typealias RawValue = NonEmpty<NonEmpty<NonEmpty<NonEmpty<[Point.Coordinates]>>>> + + public var rawValue: RawValue + + public var _bbox: RawValue.BoundingBox { rawValue.bbox } + + public init(rawValue: RawValue) throws { + guard rawValue.first == rawValue.last else { + throw LinearRingError.firstAndLastPositionsShouldBeEquivalent + } + self.rawValue = rawValue + } + + public init(elements: [Point.Coordinates]) throws { + guard let rawValue = NonEmpty(rawValue: elements) + .flatMap(NonEmpty.init(rawValue:)) + .flatMap(NonEmpty.init(rawValue:)) + .flatMap(NonEmpty.init(rawValue:)) + else { throw LinearRingError.notEnoughPoints } + + try self.init(rawValue: rawValue) + } + +} + +extension LinearRingCoordinates: ExpressibleByArrayLiteral { + + public init(arrayLiteral elements: Point.Coordinates...) { + do { + try self.init(elements: elements) + } catch { + fatalError("Array literal should contain at least 4 values.") + } + } + +} + +/// A ``Polygon`` with ``Point2D``s. +public struct Polygon2D: Polygon { + + public typealias Point = Point2D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .polygon2D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} + +/// A ``Polygon`` with ``Point3D``s. +public struct Polygon3D: Polygon { + + public typealias Point = Point3D + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .polygon3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} diff --git a/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift new file mode 100644 index 0000000..3cc1e05 --- /dev/null +++ b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift @@ -0,0 +1,18 @@ +// +// GeoJSON+Boundable.swift +// GeoSwift +// +// Created by Rémi Bardon on 05/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty +import Turf + +extension NonEmpty: Boundable where Collection: Hashable, Element: Boundable { + + public var _bbox: Element.BoundingBox { + self.reduce(nil, { $0.union($1.bbox) }) ?? .zero + } + +} diff --git a/Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift b/Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift new file mode 100644 index 0000000..6937c8a --- /dev/null +++ b/Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift @@ -0,0 +1,39 @@ +// +// NonEmpty+NonEmpty.swift +// GeoSwift +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty + +extension NonEmpty { + + /// `NonEmpty.init(rawValue:): (C) -> NonEmpty<C>?` + /// `I need: (C) -> NonEmpty<NonEmpty<C>>?` + + static func case2<C: Swift.Collection>(_ collection: C) -> NonEmpty<NonEmpty<C>>? { + if let collection: NonEmpty<C> = NonEmpty<C>(rawValue: collection) { + return NonEmpty<NonEmpty<C>>(rawValue: collection) + } else { + return nil + } + } + + init?<C: Swift.Collection>(nested rawValue: C) where Collection == NonEmpty<C> { + if let collection: NonEmpty<C> = NonEmpty<C>(rawValue: rawValue) { + self.init(rawValue: collection) + } else { + return nil + } + } + + init?<C: Swift.Collection>(nestedCollection rawValue: C) where Collection == NonEmpty<NonEmpty<C>> { + guard let a = NonEmpty<C>(rawValue: rawValue) else { return nil } + guard let b = NonEmpty<NonEmpty<C>>(rawValue: a) else { return nil } + + self.init(rawValue: b) + } + +} diff --git a/Sources/GeoJSON/Helpers/NonID.swift b/Sources/GeoJSON/Helpers/NonID.swift new file mode 100644 index 0000000..3d8a2ec --- /dev/null +++ b/Sources/GeoJSON/Helpers/NonID.swift @@ -0,0 +1,9 @@ +// +// NonID.swift +// GeoSwift +// +// Created by Rémi Bardon on 09/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public typealias NonID = Bool diff --git a/Sources/GeoJSON/Objects/Feature.swift b/Sources/GeoJSON/Objects/Feature.swift new file mode 100644 index 0000000..a076c27 --- /dev/null +++ b/Sources/GeoJSON/Objects/Feature.swift @@ -0,0 +1,60 @@ +// +// Feature.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). +public struct Feature< + ID: Codable, + Geometry: GeoJSON.Geometry & Codable, + Properties: Codable +>: CodableObject { + + public static var geoJSONType: GeoJSON.`Type` { .feature } + + public var bbox: Geometry.BoundingBox? { geometry?.bbox } + + public var id: ID? + public var geometry: Geometry? + /// The `"properties"` field of a [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). + public var properties: Properties + + public init(id: ID?, geometry: Geometry?, properties: Properties) { + self.id = id + self.geometry = geometry + self.properties = properties + } + + public init(geometry: Geometry?, properties: Properties) where ID == NonID { + self.id = nil + self.geometry = geometry + self.properties = properties + } + +} + +extension Feature: Identifiable where ID: Hashable {} +extension Feature: Equatable where ID: Equatable, Properties: Equatable { + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + && lhs.geometry == rhs.geometry + && lhs.properties == rhs.properties + } + +} +extension Feature: Hashable where ID: Hashable, Properties: Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(geometry) + hasher.combine(properties) + } + +} + +/// A (half) type-erased ``Feature``. +public typealias AnyFeature<Properties: Codable> = Feature<NonID, AnyGeometry, Properties> diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift new file mode 100644 index 0000000..bdc26ff --- /dev/null +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -0,0 +1,45 @@ +// +// FeatureCollection.swift +// GeoSwift +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3). +public struct FeatureCollection< + ID: Codable, + Geometry: GeoJSON.Geometry & Codable, + Properties: Codable +>: CodableObject { + + public static var geoJSONType: GeoJSON.`Type` { .featureCollection } + + public var features: [Feature<ID, Geometry, Properties>] + + public var bbox: AnyBoundingBox? { + features.compactMap(\.bbox).reduce(nil, { $0.union($1.asAny) }) + } + +} + +extension FeatureCollection: Equatable where ID: Equatable, Properties: Equatable { + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.features == rhs.features + } + +} + +extension FeatureCollection: Hashable where ID: Hashable, Properties: Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(features) + } + +} + +/// A (half) type-erased ``FeatureCollection``. +public typealias AnyFeatureCollection< + Properties: Codable +> = FeatureCollection<NonID, AnyGeometry, Properties> diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift new file mode 100644 index 0000000..fbcfab2 --- /dev/null +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -0,0 +1,114 @@ +// +// Geometry.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import Turf + +/// A [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1). +public protocol Geometry: GeoJSON.Object, Boundable, Hashable +where BoundingBox: GeoJSON.BoundingBox +{ + + /// This geometry, but type-erased. + var asAnyGeometry: AnyGeometry { get } + +} + +public protocol CodableGeometry: Geometry, CodableObject { + + /// The GeoJSON type of this geometry. + static var geometryType: GeoJSON.`Type`.Geometry { get } + +} + +extension CodableGeometry { + + public static var geoJSONType: GeoJSON.`Type` { .geometry(Self.geometryType) } + +} + +/// A single [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1) +/// (not a [GeometryCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8)). +public protocol SingleGeometry: CodableGeometry { + + associatedtype Coordinates: Boundable & Hashable & Codable + associatedtype BoundingBox = Coordinates.BoundingBox + + var coordinates: Coordinates { get set } + + init(coordinates: Coordinates) + +} + +extension SingleGeometry { + + public var _bbox: Coordinates.BoundingBox { coordinates.bbox } + +} + +/// A type-erased ``Geometry``. +public enum AnyGeometry: Geometry, Hashable, Codable { + + case geometryCollection(GeometryCollection) + + case point2D(Point2D) + case multiPoint2D(MultiPoint2D) + case lineString2D(LineString2D) + case multiLineString2D(MultiLineString2D) + case polygon2D(Polygon2D) + case multiPolygon2D(MultiPolygon2D) + + case point3D(Point3D) + case multiPoint3D(MultiPoint3D) + case lineString3D(LineString3D) + case multiLineString3D(MultiLineString3D) + case polygon3D(Polygon3D) + case multiPolygon3D(MultiPolygon3D) + +// public var geometryType: GeoJSON.`Type`.Geometry { +// switch self { +// case .geometryCollection(let geo): return geo.geometryType +// +// case .point2D(let geo): return geo.geometryType +// case .multiPoint2D(let geo): return geo.geometryType +// case .lineString2D(let geo): return geo.geometryType +// case .multiLineString2D(let geo): return geo.geometryType +// case .polygon2D(let geo): return geo.geometryType +// case .multiPolygon2D(let geo): return geo.geometryType +// +// case .point3D(let geo): return geo.geometryType +// case .multiPoint3D(let geo): return geo.geometryType +// case .lineString3D(let geo): return geo.geometryType +// case .multiLineString3D(let geo): return geo.geometryType +// case .polygon3D(let geo): return geo.geometryType +// case .multiPolygon3D(let geo): return geo.geometryType +// } +// } + + public var _bbox: AnyBoundingBox { + switch self { + case .geometryCollection(let geo): return geo.bbox + + case .point2D(let geo): return geo.bbox.asAny + case .multiPoint2D(let geo): return geo.bbox.asAny + case .lineString2D(let geo): return geo.bbox.asAny + case .multiLineString2D(let geo): return geo.bbox.asAny + case .polygon2D(let geo): return geo.bbox.asAny + case .multiPolygon2D(let geo): return geo.bbox.asAny + + case .point3D(let geo): return geo.bbox.asAny + case .multiPoint3D(let geo): return geo.bbox.asAny + case .lineString3D(let geo): return geo.bbox.asAny + case .multiLineString3D(let geo): return geo.bbox.asAny + case .polygon3D(let geo): return geo.bbox.asAny + case .multiPolygon3D(let geo): return geo.bbox.asAny + } + } + + public var asAnyGeometry: AnyGeometry { self } + +} diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift new file mode 100644 index 0000000..f2afa28 --- /dev/null +++ b/Sources/GeoJSON/Objects/Object.swift @@ -0,0 +1,18 @@ +// +// Object.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON Object](https://datatracker.ietf.org/doc/html/rfc7946#section-3). +public protocol Object {} + +public protocol CodableObject: GeoJSON.Object, Codable { + + /// The [GeoJSON type](https://datatracker.ietf.org/doc/html/rfc7946#section-1.4) of this + /// [GeoJSON object](https://datatracker.ietf.org/doc/html/rfc7946#section-3). + static var geoJSONType: GeoJSON.`Type` { get } + +} diff --git a/Sources/GeoJSON/Position.swift b/Sources/GeoJSON/Position.swift new file mode 100644 index 0000000..979f442 --- /dev/null +++ b/Sources/GeoJSON/Position.swift @@ -0,0 +1,23 @@ +// +// Position.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoModels +import Turf + +/// A [GeoJSON Position](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1). +public protocol Position: Boundable {} + +/// A ``Position`` with two elements (longitude and latitude). +public typealias Position2D = Coordinate2D + +extension Position2D: Position {} + +/// A ``Position`` with three elements (longitude, latitude and altitude). +public typealias Position3D = Coordinate3D + +extension Position3D: Position {} diff --git a/Sources/GeoJSON/Type.swift b/Sources/GeoJSON/Type.swift new file mode 100644 index 0000000..d6ee41d --- /dev/null +++ b/Sources/GeoJSON/Type.swift @@ -0,0 +1,53 @@ +// +// Type.swift +// GeoSwift +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// A [GeoJSON type identifier](https://datatracker.ietf.org/doc/html/rfc7946#section-1.4). +public enum `Type`: Hashable, Codable, RawRepresentable { + + /// A [GeoJSON geometry identifier](https://datatracker.ietf.org/doc/html/rfc7946#section-1.4). + public enum Geometry: String, Hashable, Codable { + case point = "Point" + case multiPoint = "MultiPoint" + case lineString = "LineString" + case multiLineString = "MultiLineString" + case polygon = "Polygon" + case multiPolygon = "MultiPolygon" + case geometryCollection = "GeometryCollection" + } + + case geometry(Geometry) + case feature + case featureCollection + + public var rawValue: String { + switch self { + case .geometry(let geometry): + return geometry.rawValue + case .feature: + return "Feature" + case .featureCollection: + return "FeatureCollection" + } + } + + public init?(rawValue: String) { + switch rawValue { + case "Feature": + self = .feature + case "FeatureCollection": + self = .featureCollection + default: + if let geometry = Geometry(rawValue: rawValue) { + self = .geometry(geometry) + } else { + return nil + } + } + } + +} diff --git a/Sources/GeoModels/2D/BoundingBox2D.swift b/Sources/GeoModels/2D/BoundingBox2D.swift new file mode 100644 index 0000000..741df10 --- /dev/null +++ b/Sources/GeoModels/2D/BoundingBox2D.swift @@ -0,0 +1,153 @@ +// +// BoundingBox2D.swift +// SwiftGeo +// +// Created by Rémi Bardon on 02/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct BoundingBox2D: Hashable { + + public var southWest: Coordinate2D + public var width: Longitude + public var height: Latitude + + public var southLatitude: Latitude { + southWest.latitude + } + public var northLatitude: Latitude { + southLatitude + height + } + public var centerLatitude: Latitude { + southLatitude + (height / 2.0) + } + public var westLongitude: Longitude { + southWest.longitude + } + public var eastLongitude: Longitude { + let longitude = westLongitude + width + + if longitude > .halfRotation { + return longitude - .fullRotation + } else { + return longitude + } + } + public var centerLongitude: Longitude { + let longitude = westLongitude + (width / 2.0) + + if longitude > .halfRotation { + return longitude - .fullRotation + } else { + return longitude + } + } + + public var northEast: Coordinate2D { + Coordinate2D(latitude: northLatitude, longitude: eastLongitude) + } + public var northWest: Coordinate2D { + Coordinate2D(latitude: northLatitude, longitude: westLongitude) + } + public var southEast: Coordinate2D { + Coordinate2D(latitude: southLatitude, longitude: westLongitude) + } + public var center: Coordinate2D { + Coordinate2D(latitude: centerLatitude, longitude: centerLongitude) + } + + public var south: Coordinate2D { + southAtLongitude(centerLongitude) + } + public var north: Coordinate2D { + northAtLongitude(centerLongitude) + } + public var west: Coordinate2D { + westAtLatitude(centerLatitude) + } + public var east: Coordinate2D { + eastAtLatitude(centerLatitude) + } + + public var crosses180thMeridian: Bool { + westLongitude > eastLongitude + } + + public init( + southWest: Coordinate2D, + width: Longitude, + height: Latitude + ) { + self.southWest = southWest + self.width = width + self.height = height + } + + public init( + southWest: Coordinate2D, + northEast: Coordinate2D + ) { + self.init( + southWest: southWest, + width: northEast.longitude - southWest.longitude, + height: northEast.latitude - southWest.latitude + ) + } + + public func southAtLongitude(_ longitude: Longitude) -> Coordinate2D { + Coordinate2D(latitude: northEast.latitude, longitude: longitude) + } + public func northAtLongitude(_ longitude: Longitude) -> Coordinate2D { + Coordinate2D(latitude: southWest.latitude, longitude: longitude) + } + public func westAtLatitude(_ latitude: Latitude) -> Coordinate2D { + Coordinate2D(latitude: latitude, longitude: southWest.longitude) + } + public func eastAtLatitude(_ latitude: Latitude) -> Coordinate2D { + Coordinate2D(latitude: latitude, longitude: northEast.longitude) + } + + public func offsetBy(dLat: Latitude = .zero, dLong: Longitude = .zero) -> BoundingBox2D { + Self.init( + southWest: southWest.offsetBy(dLat: dLat, dLong: dLong), + width: width, + height: height + ) + } + public func offsetBy(dx: Coordinate2D.X = .zero, dy: Coordinate2D.Y = .zero) -> BoundingBox2D { + Self.init( + southWest: southWest.offsetBy(dx: dx, dy: dy), + width: width, + height: height + ) + } + +} + +extension BoundingBox2D: BoundingBox { + + public static var zero: BoundingBox2D { + Self.init(southWest: .zero, width: .zero, height: .zero) + } + + /// The union of bounding boxes gives a new bounding box that encloses the given two. + public func union(_ other: BoundingBox2D) -> BoundingBox2D { + Self.init( + southWest: Coordinate2D( + latitude: min(self.southLatitude, other.southLatitude), + longitude: min(self.westLongitude, other.westLongitude) + ), + northEast: Coordinate2D( + latitude: max(self.northLatitude, other.northLatitude), + longitude: max(self.eastLongitude, other.eastLongitude) + ) + ) + } + +} + +extension BoundingBox2D { + + public func union(_ bbox3d: BoundingBox3D) -> BoundingBox3D { bbox3d.union(self) } + +} diff --git a/Sources/GeoModels/3D/BoundingBox3D.swift b/Sources/GeoModels/3D/BoundingBox3D.swift new file mode 100644 index 0000000..4c09a73 --- /dev/null +++ b/Sources/GeoModels/3D/BoundingBox3D.swift @@ -0,0 +1,121 @@ +// +// BoundingBox3D.swift +// SwiftGeo +// +// Created by Rémi Bardon on 08/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct BoundingBox3D: Hashable { + + public var twoDimensions: BoundingBox2D + public var lowAltitude: Altitude + public var zHeight: Altitude + + public var highAltitude: Altitude { + lowAltitude + zHeight + } + public var centerAltitude: Altitude { + lowAltitude + (zHeight / 2.0) + } + + public var southWestLow: Coordinate3D { + Coordinate3D(twoDimensions.southWest, altitude: lowAltitude) + } + public var northEastHigh: Coordinate3D { + Coordinate3D(twoDimensions.northEast, altitude: highAltitude) + } + public var center: Coordinate3D { + Coordinate3D(twoDimensions.center, altitude: centerAltitude) + } + + public var crosses180thMeridian: Bool { + twoDimensions.crosses180thMeridian + } + + public init(_ boundingBox2d: BoundingBox2D, lowAltitude: Altitude, zHeight: Altitude) { + self.twoDimensions = boundingBox2d + self.lowAltitude = lowAltitude + self.zHeight = zHeight + } + + public init( + southWestLow: Coordinate3D, + width: Longitude, + height: Latitude, + zHeight: Altitude + ) { + self.init( + BoundingBox2D(southWest: southWestLow.twoDimensions, width: width, height: height), + lowAltitude: southWestLow.altitude, + zHeight: zHeight + ) + } + + public init( + southWestLow: Coordinate3D, + northEastHigh: Coordinate3D + ) { + self.init( + southWestLow: southWestLow, + width: northEastHigh.longitude - southWestLow.longitude, + height: northEastHigh.latitude - southWestLow.latitude, + zHeight: northEastHigh.altitude - southWestLow.altitude + ) + } + + public func offsetBy( + dLat: Latitude = .zero, + dLong: Longitude = .zero, + dAlt: Altitude = .zero + ) -> BoundingBox3D { + Self.init( + twoDimensions.offsetBy(dLat: dLat, dLong: dLong), + lowAltitude: lowAltitude + dAlt, + zHeight: zHeight + ) + } + public func offsetBy( + dx: Coordinate3D.X = .zero, + dy: Coordinate3D.Y = .zero, + dz: Coordinate3D.Z = .zero + ) -> BoundingBox3D { + Self.init( + twoDimensions.offsetBy(dx: dx, dy: dy), + lowAltitude: lowAltitude + dz, + zHeight: zHeight + ) + } + +} + +extension BoundingBox3D: BoundingBox { + + public static var zero: BoundingBox3D { + BoundingBox3D(.zero, lowAltitude: .zero, zHeight: .zero) + } + + /// The union of bounding boxes gives a new bounding box that encloses the given two. + public func union(_ other: BoundingBox3D) -> BoundingBox3D { + BoundingBox3D( + southWestLow: Coordinate3D( + self.twoDimensions.union(other.twoDimensions).southWest, + altitude: min(self.lowAltitude, other.lowAltitude) + ), + northEastHigh: Coordinate3D( + self.twoDimensions.union(other.twoDimensions).northEast, + altitude: max(self.highAltitude, other.highAltitude) + ) + ) + } + +} + +extension BoundingBox3D { + + public func union(_ bbox2d: BoundingBox2D) -> BoundingBox3D { + let other = BoundingBox3D(bbox2d, lowAltitude: self.lowAltitude, zHeight: .zero) + return self.union(other) + } + +} diff --git a/Sources/GeoModels/BoundingBox.swift b/Sources/GeoModels/BoundingBox.swift new file mode 100644 index 0000000..7d51e48 --- /dev/null +++ b/Sources/GeoModels/BoundingBox.swift @@ -0,0 +1,23 @@ +// +// BoundingBox.swift +// SwiftGeo +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public protocol BoundingBox: Hashable { + + static var zero: Self { get } + + func union(_ other: Self) -> Self + +} + +extension Optional where Wrapped: BoundingBox { + + public func union(_ bbox: Wrapped) -> Wrapped { + self?.union(bbox) ?? bbox + } + +} diff --git a/Sources/Turf/BoundingBoxCache.swift b/Sources/Turf/BoundingBoxCache.swift new file mode 100644 index 0000000..3a6e7cd --- /dev/null +++ b/Sources/Turf/BoundingBoxCache.swift @@ -0,0 +1,41 @@ +// +// BoundingBoxCache.swift +// SwiftGeo +// +// Created by Rémi Bardon on 10/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoModels + +public class BoundingBoxCache { + + public static let shared = BoundingBoxCache() + + private var values = [AnyHashable: Any]() + + private init() {} + + internal func store<B: BoundingBox, K: Hashable>(_ value: B, forKey key: K) { + values[AnyHashable(key)] = value + } + + internal func get<B: BoundingBox, K: Hashable>(_ type: B.Type, forKey key: K) -> B? { + values[AnyHashable(key)] as? B + } + + public func bbox<B: Boundable & Hashable>(for boundable: B) -> B.BoundingBox { + if let cachedValue = self.get(B.BoundingBox.self, forKey: boundable) { + return cachedValue + } else { + let bbox = boundable._bbox + self.store(bbox, forKey: boundable) + return bbox + } + } + + public func removeCache<K: Hashable>(for key: K) { + values.removeValue(forKey: AnyHashable(key)) + } + +} diff --git a/Sources/Turf/Turf.swift b/Sources/Turf/Turf.swift new file mode 100644 index 0000000..68d10af --- /dev/null +++ b/Sources/Turf/Turf.swift @@ -0,0 +1,150 @@ +// +// Turf.swift +// SwiftGeo +// +// Created by Rémi Bardon on 20/04/2021. +// Copyright © 2021 Rémi Bardon. All rights reserved. +// + +import Algorithms +import GeoModels + +// FIXME: Fix formulae so they handle crossing the anti meridian + +/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) of a polygon. +/// +/// - Warning: As stated in [section 3.1.6 of FRC 7946](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6), +/// polygon ring MUST follow the right-hand rule for orientation (counterclockwise). +//public func bbox(forPolygon coords: [Coordinate2D]) -> BoundingBox2D? { +// if coords.adjacentPairs().contains(where: { Line2D(start: $0, end: $1).crosses180thMeridian }) { +// let offsetCoords = coords.map(\.withPositiveLongitude) +// +// return Turf.bbox(for: offsetCoords) +// } else { +// return Turf.bbox(for: coords) +// } +//} + +/// Returns the minimum straight [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) +/// enclosing the points. +public func minimumBBox(for coords: [Coordinate2D]) -> BoundingBox2D? { + guard let bbox = Turf.bbox(for: coords) else { return nil } + + if bbox.width > .halfRotation { + let offsetCoords = coords.map(\.withPositiveLongitude) + + return Turf.bbox(for: offsetCoords) + } else { + return bbox + } +} + +/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) enclosing the points. +public func bbox<C: Collection>(for coords: C) -> BoundingBox2D? where C.Element == Coordinate2D { + guard let (south, north) = coords.map(\.latitude).minAndMax(), + let (west, east) = coords.map(\.longitude).minAndMax() + else { return nil } + + return BoundingBox2D( + southWest: Coordinate2D(latitude: south, longitude: west), + northEast: Coordinate2D(latitude: north, longitude: east) + ) +} + +/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) enclosing the points. +public func bbox<C: Collection>(for coords: C) -> BoundingBox3D? where C.Element == Coordinate3D { + guard let (south, north) = coords.map(\.latitude).minAndMax(), + let (west, east) = coords.map(\.longitude).minAndMax(), + let (low, high) = coords.map(\.altitude).minAndMax() + else { return nil } + + return BoundingBox3D( + southWestLow: Coordinate3D(latitude: south, longitude: west, altitude: low), + northEastHigh: Coordinate3D(latitude: north, longitude: east, altitude: high) + ) +} + +/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) enclosing all elements. +public func bbox<T: Boundable, C: Collection>(for boundables: C) -> T.BoundingBox? where C.Element == T { + guard !boundables.isEmpty else { return nil } + + return boundables.reduce(nil, { $0.union($1.bbox) }) ?? .zero +} + +/// Returns the absolute center of a polygon. +/// +/// Ported from <https://github.com/Turfjs/turf/blob/84110709afda447a686ccdf55724af6ca755c1f8/packages/turf-center/index.ts#L36-L44> +public func center(for coords: [Coordinate2D]) -> Coordinate2D? { + return bbox(for: coords)?.center +} + +/// Returns the [center of mass](https://en.wikipedia.org/wiki/Center_of_mass) of a polygon. +/// Used formula: [Centroid of Polygon](https://en.wikipedia.org/wiki/Centroid#Centroid_of_polygon) +/// +/// Ported from <https://github.com/Turfjs/turf/blob/84110709afda447a686ccdf55724af6ca755c1f8/packages/turf-center-of-mass/index.ts#L32-L86> +public func centerOfMass(for coords: [Coordinate2D]) -> Coordinate2D { + // First, we neutralize the feature (set it around coordinates [0,0]) to prevent rounding errors + // We take any point to translate all the points around 0 + let centre: Coordinate2D = centroid(for: coords) + let translation: Coordinate2D = centre + var sx: Double = 0 + var sy: Double = 0 + var sArea: Double = 0 + + let neutralizedPoints: [Coordinate2D] = coords.map { $0 - translation } + + for i in 0..<coords.count - 1 { + // pi is the current point + let pi: Coordinate2D = neutralizedPoints[i] + let xi: Double = pi.longitude.decimalDegrees + let yi: Double = pi.latitude.decimalDegrees + + // pj is the next point (pi+1) + let pj: Coordinate2D = neutralizedPoints[i + 1] + let xj: Double = pj.longitude.decimalDegrees + let yj: Double = pj.latitude.decimalDegrees + + // a is the common factor to compute the signed area and the final coordinates + let a: Double = xi * yj - xj * yi + + // sArea is the sum used to compute the signed area + sArea += a + + // sx and sy are the sums used to compute the final coordinates + sx += (xi + xj) * a + sy += (yi + yj) * a + } + + // Shape has no area: fallback on turf.centroid + if (sArea == 0) { + return centre + } else { + // Compute the signed area, and factorize 1/6A + let area: Double = sArea * 0.5 + let areaFactor: Double = 1 / (6 * area) + + // Compute the final coordinates, adding back the values that have been neutralized + return Coordinate2D( + latitude: translation.latitude + Latitude(areaFactor * sy), + longitude: translation.longitude + Longitude(areaFactor * sx) + ) + } +} + +/// Calculates the centroid of a polygon using the mean of all vertices. +/// +/// Ported from <https://github.com/Turfjs/turf/blob/84110709afda447a686ccdf55724af6ca755c1f8/packages/turf-centroid/index.ts#L21-L40> +public func centroid(for coordinates: [Coordinate2D]) -> Coordinate2D { + var sumLongitude: Longitude = .zero + var sumLatitude: Latitude = .zero + + for coordinate in coordinates { + sumLongitude += coordinate.longitude + sumLatitude += coordinate.latitude + } + + return Coordinate2D( + latitude: sumLatitude / Latitude(coordinates.count), + longitude: sumLongitude / Longitude(coordinates.count) + ) +} diff --git a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift new file mode 100644 index 0000000..bdbd869 --- /dev/null +++ b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift @@ -0,0 +1,397 @@ +// +// GeoJSON+DecodableTests.swift +// SwiftGeo +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +@testable import GeoJSON +import XCTest + +final class GeoJSONDecodableTests: XCTestCase { + + // MARK: Specification tests + + func testPosition2DDecode() throws { + let string: String = "[-1.55366,47.21881]" + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let position = try JSONDecoder().decode(Position2D.self, from: data) + + let expected: Position2D = .nantes + XCTAssertEqual(position, expected) + } + + func testPoint2DDecode() throws { + let string: String = [ + "{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881]", + "}", + ].joined() + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let point = try JSONDecoder().decode(Point2D.self, from: data) + + let expected: Point2D = Point2D(coordinates: .nantes) + XCTAssertEqual(point, expected) + } + + func testMultiPoint2DDecode() throws { + let string: String = [ + "{", + "\"type\":\"MultiPoint\",", + "\"coordinates\":[", + "[-1.55366,47.21881]", + "]", + "}", + ].joined() + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let multiPoint = try JSONDecoder().decode(MultiPoint2D.self, from: data) + + let expected: MultiPoint2D = MultiPoint2D(coordinates: [.nantes]) + XCTAssertEqual(multiPoint, expected) + } + + func testLineString2DDecode() throws { + let string: String = [ + "{", + "\"type\":\"LineString\",", + "\"coordinates\":[", + "[-1.55366,47.21881],", + "[2.3529,48.85719]", + "]", + "}", + ].joined() + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let lineString = try JSONDecoder().decode(LineString2D.self, from: data) + + let expected: LineString2D = try XCTUnwrap(LineString2D(coordinates: [.nantes, .paris])) + XCTAssertEqual(lineString, expected) + } + + func testMultiLineString2DDecode() throws { + let string: String = [ + "{", + "\"type\":\"MultiLineString\",", + "\"coordinates\":[", + "[", + "[-1.55366,47.21881],", + "[-0.58143,44.8378]", + "],", + "[", + "[2.3529,48.85719],", + "[5.36468,43.29868]", + "]", + "]", + "}", + ].joined() + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let multiLineString = try JSONDecoder().decode(MultiLineString2D.self, from: data) + + let expected: MultiLineString2D = try XCTUnwrap(MultiLineString2D(coordinates: [ + [.nantes, .bordeaux], + [.paris, .marseille], + ])) + XCTAssertEqual(multiLineString, expected) + } + + func testPolygon2DDecode() throws { + let string = [ + "{", + "\"type\":\"Polygon\",", + "\"coordinates\":[", + "[", + "[-1.55366,47.21881],", + "[-0.58143,44.8378],", + "[5.36468,43.29868],", + "[2.3529,48.85719],", + "[-1.55366,47.21881]", + "]", + "]", + "}", + ].joined() + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let polygon = try JSONDecoder().decode(Polygon2D.self, from: data) + + let expected: Polygon2D = Polygon2D(coordinates: [ + [.nantes, .bordeaux, .marseille, .paris, .nantes], + ]) + XCTAssertEqual(polygon, expected) + } + + func testMultiPolygon2DDecode() throws { + let string: String = [ + "{", + "\"type\":\"MultiPolygon\",", + "\"coordinates\":[", + "[", + "[", + "[-1.55366,47.21881],", + "[-0.58143,44.8378],", + "[5.36468,43.29868],", + "[2.3529,48.85719],", + "[-1.55366,47.21881]", + "]", + "]", + "]", + "}", + ].joined() + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + + let multiPolygon = try JSONDecoder().decode(MultiPolygon2D.self, from: data) + + let expected: MultiPolygon2D = try XCTUnwrap(MultiPolygon2D(coordinates: [ + [ + [.nantes, .bordeaux, .marseille, .paris, .nantes], + ], + ])) + XCTAssertEqual(multiPolygon, expected) + } + + func testFeature2DDecode() throws { + struct FeatureProperties: Hashable, Codable {} + + let string: String = [ + "{", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881]", + "},", + "\"properties\":{},", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "}", + ].joined() + + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + let feature = try JSONDecoder().decode(Feature<NonID, Point2D, FeatureProperties>.self, from: data) + + let expected: Feature = Feature( + geometry: Point2D(coordinates: .nantes), + properties: FeatureProperties() + ) + XCTAssertEqual(feature, expected) + } + + func testFeature2DWithIDDecode() throws { + struct FeatureProperties: Hashable, Codable {} + + let string: String = [ + "{", + "\"type\":\"Feature\",", + "\"id\":\"feature_id\",", + "\"geometry\":{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881]", + "},", + "\"properties\":{},", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "}", + ].joined() + + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + let feature = try JSONDecoder().decode(Feature<String, Point2D, FeatureProperties>.self, from: data) + + let expected: Feature = Feature( + id: "feature_id", + geometry: Point2D(coordinates: .nantes), + properties: FeatureProperties() + ) + XCTAssertEqual(feature, expected) + } + + // MARK: Real-world use cases + + func testDecodeFeatureProperties() throws { + struct RealWorldProperties: Hashable, Codable { + let prop0: String + } + + let string = """ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": { + "prop0": "value0" + } + } + """ + + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + let feature = try JSONDecoder().decode(AnyFeature<RealWorldProperties>.self, from: data) + + let expected: AnyFeature = AnyFeature( + geometry: .point2D(Point2D(coordinates: .init(latitude: 0.5, longitude: 102))), + properties: RealWorldProperties(prop0: "value0") + ) + XCTAssertEqual(feature, expected) + } + + /// Example from [RFC 7946, section 1.5](https://datatracker.ietf.org/doc/html/rfc7946#section-1.5). + func testDecodeHeterogeneousFeatureCollection() throws { + enum HeterogeneousProperties: Hashable, Codable, CustomStringConvertible { + struct Properties1: Hashable, Codable, CustomStringConvertible { + let prop0: String + + var description: String { + "{prop0:\"\(prop0)\"}" + } + } + struct Properties2: Hashable, Codable, CustomStringConvertible { + let prop0: String + let prop1: Double + + var description: String { + "{prop0:\"\(prop0)\",prop1:\(prop1)}" + } + } + struct Properties3: Hashable, Codable, CustomStringConvertible { + struct Prop1: Hashable, Codable, CustomStringConvertible { + let this: String + + var description: String { + "{this:\"\(this)\"}" + } + } + + let prop0: String + let prop1: Prop1 + + var description: String { + "{prop0:\"\(prop0)\",prop1:\(prop1)}" + } + } + + enum CodingKeys: String, CodingKey { + case prop0, prop1 + } + + case type1(Properties1) + case type2(Properties2) + case type3(Properties3) + + var description: String { + switch self { + case .type1(let type1): + return type1.description + case .type2(let type2): + return type2.description + case .type3(let type3): + return type3.description + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let prop0 = try container.decode(String.self, forKey: .prop0) + do { + do { + let prop1 = try container.decode(Double.self, forKey: .prop1) + self = .type2(.init(prop0: prop0, prop1: prop1)) + } catch { + let prop1 = try container.decode(Properties3.Prop1.self, forKey: .prop1) + self = .type3(.init(prop0: prop0, prop1: prop1)) + } + } catch { + self = .type1(.init(prop0: prop0)) + } + } + + func encode(to encoder: Encoder) throws { + fatalError("Useless") + } + } + + let string = """ + { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": { + "prop0": "value0" + } + }, { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [102.0, 0.0], + [103.0, 1.0], + [104.0, 0.0], + [105.0, 1.0] + ] + }, + "properties": { + "prop0": "value0", + "prop1": 0.0 + } + }, { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ] + ] + }, + "properties": { + "prop0": "value0", + "prop1": { + "this": "that" + } + } + }] + } + """ + + let data: Data = try XCTUnwrap(string.data(using: .utf8)) + let feature = try JSONDecoder().decode(AnyFeatureCollection<HeterogeneousProperties>.self, from: data) + + let expected: AnyFeatureCollection<HeterogeneousProperties> = AnyFeatureCollection(features: [ + Feature( + geometry: .point2D(Point2D(coordinates: .init(latitude: 0.5, longitude: 102))), + properties: .type1(.init(prop0: "value0")) + ), + Feature( + geometry: .lineString2D(.init(coordinates: [ + .init(latitude: 0.0, longitude: 102.0), + .init(latitude: 1.0, longitude: 103.0), + .init(latitude: 0.0, longitude: 104.0), + .init(latitude: 1.0, longitude: 105.0) + ])!), + properties: .type2(.init(prop0: "value0", prop1: 0)) + ), + Feature( + geometry: .polygon2D(.init(coordinates: .init(arrayLiteral: [ + .init(latitude: 0.0, longitude: 100.0), + .init(latitude: 0.0, longitude: 101.0), + .init(latitude: 1.0, longitude: 101.0), + .init(latitude: 1.0, longitude: 100.0), + .init(latitude: 0.0, longitude: 100.0) + ]))), + properties: .type3(.init(prop0: "value0", prop1: .init(this: "that"))) + ), + ]) + XCTAssertEqual(feature, expected) + } + +} diff --git a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift new file mode 100644 index 0000000..725f8fb --- /dev/null +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -0,0 +1,282 @@ +// +// GeoJSON+EncodableTests.swift +// SwiftGeo +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +@testable import GeoJSON +import XCTest + +final class GeoJSONEncodableTests: XCTestCase { + + func testPosition2DEncode() throws { + let data = try JSONEncoder().encode(Position2D.nantes) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, "[-1.55366,47.21881]") + } + + func testPoint2DEncode() throws { + let point = Point2D(coordinates: .nantes) + let data = try JSONEncoder().encode(point) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, [ + "{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "}", + ].joined()) + } + + func testMultiPoint2DEncode() throws { + let multiPoint = MultiPoint2D(coordinates: [.nantes]) + let data = try JSONEncoder().encode(multiPoint) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, [ + "{", + "\"type\":\"MultiPoint\",", + "\"coordinates\":[", + "[-1.55366,47.21881]", + "],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "}", + ].joined()) + } + + func testLineString2DEncode() throws { + let lineString = try XCTUnwrap(LineString2D(coordinates: [.nantes, .paris])) + let data = try JSONEncoder().encode(lineString) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, [ + "{", + "\"type\":\"LineString\",", + "\"coordinates\":[", + "[-1.55366,47.21881],", + "[2.3529,48.85719]", + "],", + "\"bbox\":[-1.55366,47.21881,2.3529,48.85719]", + "}", + ].joined()) + } + + func testMultiLineString2DEncode() throws { + let multiLineString = try XCTUnwrap(MultiLineString2D(coordinates: [[.nantes, .bordeaux], [.paris, .marseille]])) + let data = try JSONEncoder().encode(multiLineString) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, [ + "{", + "\"type\":\"MultiLineString\",", + "\"coordinates\":[", + "[", + "[-1.55366,47.21881],", + "[-0.58143,44.8378]", + "],", + "[", + "[2.3529,48.85719],", + "[5.36468,43.29868]", + "]", + "],", + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719]", + "}", + ].joined()) + } + + func testPolygon2DEncode() throws { + let polygon = try XCTUnwrap(Polygon2D(coordinates: [ + [.nantes, .bordeaux, .marseille, .paris, .nantes], + ])) + let data = try JSONEncoder().encode(polygon) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, [ + "{", + "\"type\":\"Polygon\",", + "\"coordinates\":[", + "[", + "[-1.55366,47.21881],", + "[-0.58143,44.8378],", + "[5.36468,43.29868],", + "[2.3529,48.85719],", + "[-1.55366,47.21881]", + "]", + "],", + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719]", + "}", + ].joined()) + } + + // testPolygonNeeds4Points + // testPolygonIsClockwise + // testPolygon2DCrossingAntiMeridianEncode + + func testMultiPolygon2DEncode() throws { + let multiPolygon = try XCTUnwrap(MultiPolygon2D(coordinates: [ + [ + [.nantes, .bordeaux, .marseille, .paris, .nantes], + ], + ])) + let data = try JSONEncoder().encode(multiPolygon) + let string = String(data: data, encoding: .utf8) + + XCTAssertEqual(string, [ + "{", + "\"type\":\"MultiPolygon\",", + "\"coordinates\":[", + "[", + "[", + "[-1.55366,47.21881],", + "[-0.58143,44.8378],", + "[5.36468,43.29868],", + "[2.3529,48.85719],", + "[-1.55366,47.21881]", + "]", + "]", + "],", + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719]", + "}", + ].joined()) + } + + func testFeature2DEncode() throws { + struct FeatureProperties: Hashable, Codable {} + + let feature: Feature = Feature( + geometry: Point2D(coordinates: .nantes), + properties: FeatureProperties() + ) + let data: Data = try JSONEncoder().encode(feature) + let string: String = try XCTUnwrap(String(data: data, encoding: .utf8)) + + let expected: String = [ + "{", + // For some reason, `"properties"` goes on top 🤷 + "\"properties\":{},", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "},", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "}", + ].joined() + XCTAssertEqual(string, expected) + } + + func testFeature2DWithIDEncode() throws { + struct FeatureProperties: Hashable, Codable {} + + let feature: Feature = Feature( + id: "feature_id", + geometry: Point2D(coordinates: .nantes), + properties: FeatureProperties() + ) + let data: Data = try JSONEncoder().encode(feature) + let string: String = try XCTUnwrap(String(data: data, encoding: .utf8)) + + let expected: String = [ + "{", + // For some reason, `"id"` goes here 🤷 + "\"id\":\"feature_id\",", + // For some reason, `"properties"` goes here 🤷 + "\"properties\":{},", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "},", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "}", + ].joined() + XCTAssertEqual(string, expected) + } + + func testFeatureCollectionOfGeometryCollectionEncode() throws { + struct FeatureProperties: Hashable, Codable {} + + let featureCollection: FeatureCollection = FeatureCollection(features: [ + Feature( + geometry: GeometryCollection(geometries: [ + .point2D(Point2D(coordinates: .nantes)), + .point2D(Point2D(coordinates: .bordeaux)), + ]), + properties: FeatureProperties() + ), + Feature( + geometry: GeometryCollection(geometries: [ + .point2D(Point2D(coordinates: .paris)), + .point2D(Point2D(coordinates: .marseille)), + ]), + properties: FeatureProperties() + ), + ]) + let data: Data = try JSONEncoder().encode(featureCollection) + let string: String = try XCTUnwrap(String(data: data, encoding: .utf8)) + + let expected: String = [ + "{", + "\"type\":\"FeatureCollection\",", + // For some reason, `"bbox"` goes here 🤷 + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719],", + "\"features\":[", + "{", + // For some reason, `"properties"` goes here 🤷 + "\"properties\":{},", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"GeometryCollection\",", + // For some reason, `"bbox"` goes here 🤷 + "\"bbox\":[-1.55366,44.8378,-0.58143,47.21881],", + "\"geometries\":[", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "},", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[-0.58143,44.8378],", + "\"bbox\":[-0.58143,44.8378,-0.58143,44.8378]", + "}", + "]", + "},", + "\"bbox\":[-1.55366,44.8378,-0.58143,47.21881]", + "},", + "{", + // For some reason, `"properties"` goes here 🤷 + "\"properties\":{},", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"GeometryCollection\",", + // For some reason, `"bbox"` goes here 🤷 + "\"bbox\":[2.3529,43.29868,5.36468,48.85719],", + "\"geometries\":[", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[2.3529,48.85719],", + "\"bbox\":[2.3529,48.85719,2.3529,48.85719]", + "},", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[5.36468,43.29868],", + "\"bbox\":[5.36468,43.29868,5.36468,43.29868]", + "}", + "]", + "},", + "\"bbox\":[2.3529,43.29868,5.36468,48.85719]", + "}", + "]", + "}", + ].joined() + XCTAssertEqual(string, expected) + } + +} diff --git a/Tests/GeoJSONTests/Position+TestValues.swift b/Tests/GeoJSONTests/Position+TestValues.swift new file mode 100644 index 0000000..0d9a165 --- /dev/null +++ b/Tests/GeoJSONTests/Position+TestValues.swift @@ -0,0 +1,18 @@ +// +// Position+TestValues.swift +// GeoSwift +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoJSON + +extension Position2D { + + static var paris: Self { Position2D(latitude: 48.85719, longitude: 2.35290) } + static var nantes: Self { Position2D(latitude: 47.21881, longitude: -1.55366) } + static var bordeaux: Self { Position2D(latitude: 44.83780, longitude: -0.58143) } + static var marseille: Self { Position2D(latitude: 43.29868, longitude: 5.36468) } + +}