From 30e4b4ca01092d52ee06943c7f599b50996f60d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Fri, 4 Feb 2022 21:02:45 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20`Codable`=20GeoJSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 14 + Sources/GeoJSON/BoundingBox.swift | 31 ++ Sources/GeoJSON/Errors.swift | 15 + Sources/GeoJSON/GeoJSON+Codable.swift | 286 ++++++++++++++++++ .../Geometries/GeometryCollection.swift | 19 ++ Sources/GeoJSON/Geometries/LineString.swift | 40 +++ .../GeoJSON/Geometries/MultiLineString.swift | 46 +++ Sources/GeoJSON/Geometries/MultiPoint.swift | 30 ++ Sources/GeoJSON/Geometries/MultiPolygon.swift | 30 ++ Sources/GeoJSON/Geometries/Point.swift | 30 ++ Sources/GeoJSON/Geometries/Polygon.swift | 73 +++++ .../GeoJSON/Helpers/GeoJSON+Boundable.swift | 18 ++ .../GeoJSON/Helpers/NonEmpty+NonEmpty.swift | 39 +++ Sources/GeoJSON/Objects/Feature.swift | 27 ++ .../GeoJSON/Objects/FeatureCollection.swift | 21 ++ Sources/GeoJSON/Objects/Geometry.swift | 75 +++++ Sources/GeoJSON/Objects/Object.swift | 17 ++ Sources/GeoJSON/Position.swift | 16 + Sources/GeoJSON/Properties.swift | 9 + Sources/GeoJSON/Type.swift | 51 ++++ .../GeoJSONTests/GeoJSON+DecodableTests.swift | 180 +++++++++++ .../GeoJSONTests/GeoJSON+EncodableTests.swift | 166 ++++++++++ Tests/GeoJSONTests/Position+TestValues.swift | 18 ++ 23 files changed, 1251 insertions(+) create mode 100644 Sources/GeoJSON/BoundingBox.swift create mode 100644 Sources/GeoJSON/Errors.swift create mode 100644 Sources/GeoJSON/GeoJSON+Codable.swift create mode 100644 Sources/GeoJSON/Geometries/GeometryCollection.swift create mode 100644 Sources/GeoJSON/Geometries/LineString.swift create mode 100644 Sources/GeoJSON/Geometries/MultiLineString.swift create mode 100644 Sources/GeoJSON/Geometries/MultiPoint.swift create mode 100644 Sources/GeoJSON/Geometries/MultiPolygon.swift create mode 100644 Sources/GeoJSON/Geometries/Point.swift create mode 100644 Sources/GeoJSON/Geometries/Polygon.swift create mode 100644 Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift create mode 100644 Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift create mode 100644 Sources/GeoJSON/Objects/Feature.swift create mode 100644 Sources/GeoJSON/Objects/FeatureCollection.swift create mode 100644 Sources/GeoJSON/Objects/Geometry.swift create mode 100644 Sources/GeoJSON/Objects/Object.swift create mode 100644 Sources/GeoJSON/Position.swift create mode 100644 Sources/GeoJSON/Properties.swift create mode 100644 Sources/GeoJSON/Type.swift create mode 100644 Tests/GeoJSONTests/GeoJSON+DecodableTests.swift create mode 100644 Tests/GeoJSONTests/GeoJSON+EncodableTests.swift create mode 100644 Tests/GeoJSONTests/Position+TestValues.swift diff --git a/Package.swift b/Package.swift index 359ba0e..1c0b258 100644 --- a/Package.swift +++ b/Package.swift @@ -137,5 +137,19 @@ let package = Package( "GeodeticConversions", "WGS84Conversions", ]), + + // 📄 GeoJSON representation + .target( + name: "GeoJSON", + dependencies: [ + .target(name: "GeoModels"), + .target(name: "Turf"), + .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..c6d971c --- /dev/null +++ b/Sources/GeoJSON/BoundingBox.swift @@ -0,0 +1,31 @@ +// +// BoundingBox.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoModels + +public protocol BoundingBox: Hashable, Codable { + + var asAny: AnyBoundingBox { get } + +} + +public typealias BoundingBox2D = GeoModels.BoundingBox2D + +extension BoundingBox2D: BoundingBox { + + public var asAny: AnyBoundingBox { .twoDimensions(self) } + +} + +public enum AnyBoundingBox: BoundingBox, Hashable, Codable { + + case twoDimensions(BoundingBox2D) + + public var asAny: AnyBoundingBox { self } + +} diff --git a/Sources/GeoJSON/Errors.swift b/Sources/GeoJSON/Errors.swift new file mode 100644 index 0000000..bb1e2b3 --- /dev/null +++ b/Sources/GeoJSON/Errors.swift @@ -0,0 +1,15 @@ +// +// Errors.swift +// SwiftGeo +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import Foundation + +/// 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..a5b034d --- /dev/null +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -0,0 +1,286 @@ +// +// 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 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 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 +} + +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) + } + +} + +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() + + // FIXME: 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: + let multiPoint2D = try container.decode(MultiPoint2D.self) + self = .multiPoint2D(multiPoint2D) + case .lineString: + let lineString2D = try container.decode(LineString2D.self) + self = .lineString2D(lineString2D) + case .multiLineString: + let multiLineString2D = try container.decode(MultiLineString2D.self) + self = .multiLineString2D(multiLineString2D) + case .polygon: + let polygon2D = try container.decode(Polygon2D.self) + self = .polygon2D(polygon2D) + case .multiPolygon: + 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) + } + } + +} + +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 AnyBoundingBox { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + 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) + } + } + +} + +fileprivate enum FeatureCodingKeys: String, CodingKey { + case geoJSONType = "type" + case geometry, properties, bbox +} + +extension Feature { + + public init(from decoder: Decoder) throws { + 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 geometry = try container.decodeIfPresent(AnyGeometry.self, forKey: .geometry) + let properties = try container.decode(Properties.self, forKey: .properties) + + self.init(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.geometry, forKey: .geometry) + try container.encode(self.properties, forKey: .properties) + // TODO: Create GeoJSONEncoder that allows setting "export bboxes" to a boolean value + // TODO: Memoize bboxes not to recompute them all the time (bboxes tree) + try container.encodeIfPresent(self.bbox, forKey: .bbox) + } + +} diff --git a/Sources/GeoJSON/Geometries/GeometryCollection.swift b/Sources/GeoJSON/Geometries/GeometryCollection.swift new file mode 100644 index 0000000..340d3fe --- /dev/null +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -0,0 +1,19 @@ +// +// GeometryCollection.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct GeometryCollection: Geometry { + + public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection} + + public var bbox: AnyBoundingBox? + + public var asAnyGeometry: AnyGeometry { .geometryCollection(self) } + + public var geometries: [AnyGeometry] + +} diff --git a/Sources/GeoJSON/Geometries/LineString.swift b/Sources/GeoJSON/Geometries/LineString.swift new file mode 100644 index 0000000..e1f9794 --- /dev/null +++ b/Sources/GeoJSON/Geometries/LineString.swift @@ -0,0 +1,40 @@ +// +// LineString.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty + +public protocol LineString: SingleGeometry { + + associatedtype Position: GeoJSON.Position + associatedtype Coordinates = NonEmpty> + +} + +public struct LineString2D: LineString { + + public typealias Position = Position2D + + public static var geometryType: GeoJSON.`Type`.Geometry { .lineString } + + 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) + } + +} diff --git a/Sources/GeoJSON/Geometries/MultiLineString.swift b/Sources/GeoJSON/Geometries/MultiLineString.swift new file mode 100644 index 0000000..328ab12 --- /dev/null +++ b/Sources/GeoJSON/Geometries/MultiLineString.swift @@ -0,0 +1,46 @@ +// +// MultiLineString.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty + +public protocol MultiLineString: SingleGeometry { + + associatedtype LineString: GeoJSON.LineString + associatedtype Coordinates = [LineString.Coordinates] + +} + +public struct MultiLineString2D: MultiLineString { + + public typealias LineString = LineString2D + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiLineString } + + 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) + } + +} diff --git a/Sources/GeoJSON/Geometries/MultiPoint.swift b/Sources/GeoJSON/Geometries/MultiPoint.swift new file mode 100644 index 0000000..70bfe88 --- /dev/null +++ b/Sources/GeoJSON/Geometries/MultiPoint.swift @@ -0,0 +1,30 @@ +// +// MultiPoint.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public protocol MultiPoint: SingleGeometry { + + associatedtype Point: GeoJSON.Point + associatedtype Coordinates = [Point.Coordinates] + +} + +public struct MultiPoint2D: MultiPoint { + + public typealias Point = Point2D + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPoint } + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiPoint2D(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..24954b9 --- /dev/null +++ b/Sources/GeoJSON/Geometries/MultiPolygon.swift @@ -0,0 +1,30 @@ +// +// MultiPolygon.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public protocol MultiPolygon: SingleGeometry { + + associatedtype Polygon: GeoJSON.Polygon + associatedtype Coordinates = [Polygon.Coordinates] + +} + +public struct MultiPolygon2D: MultiPolygon { + + public typealias Polygon = Polygon2D + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPolygon } + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .multiPolygon2D(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..7c6c173 --- /dev/null +++ b/Sources/GeoJSON/Geometries/Point.swift @@ -0,0 +1,30 @@ +// +// Point.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public protocol Point: SingleGeometry { + + associatedtype Position: GeoJSON.Position + associatedtype Coordinates = Position + +} + +public struct Point2D: Point { + + public typealias Position = Position2D + + public static var geometryType: GeoJSON.`Type`.Geometry { .point } + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .point2D(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..3df2c59 --- /dev/null +++ b/Sources/GeoJSON/Geometries/Polygon.swift @@ -0,0 +1,73 @@ +// +// Polygon.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import NonEmpty +import Turf + +public protocol Polygon: SingleGeometry { + + associatedtype Point: GeoJSON.Point + associatedtype Coordinates = [LinearRingCoordinates] + +} + +public struct LinearRingCoordinates: Boundable, Hashable, Codable { + + public typealias RawValue = NonEmpty>>> + + 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 / linear ring. +public struct Polygon2D: Polygon { + + public typealias Point = Point2D + + public static var geometryType: GeoJSON.`Type`.Geometry { .polygon } + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .polygon2D(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..0e7f8df --- /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 Element: Boundable { + + public var bbox: Element.BoundingBox { + self.reduce(.zero, { $0.union($1.bbox) }) + } + +} 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?` + /// `I need: (C) -> NonEmpty>?` + + static func case2(_ collection: C) -> NonEmpty>? { + if let collection: NonEmpty = NonEmpty(rawValue: collection) { + return NonEmpty>(rawValue: collection) + } else { + return nil + } + } + + init?(nested rawValue: C) where Collection == NonEmpty { + if let collection: NonEmpty = NonEmpty(rawValue: rawValue) { + self.init(rawValue: collection) + } else { + return nil + } + } + + init?(nestedCollection rawValue: C) where Collection == NonEmpty> { + guard let a = NonEmpty(rawValue: rawValue) else { return nil } + guard let b = NonEmpty>(rawValue: a) else { return nil } + + self.init(rawValue: b) + } + +} diff --git a/Sources/GeoJSON/Objects/Feature.swift b/Sources/GeoJSON/Objects/Feature.swift new file mode 100644 index 0000000..c8f1d90 --- /dev/null +++ b/Sources/GeoJSON/Objects/Feature.swift @@ -0,0 +1,27 @@ +// +// Feature.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct Feature< +// Geometry: GeoJSON.Geometry, +// BoundingBox: GeoJSON.BoundingBox, + Properties: GeoJSON.Properties +>: GeoJSON.Object, Hashable, Codable { + + public static var geoJSONType: GeoJSON.`Type` { .feature } + + public var bbox: AnyBoundingBox? { geometry?.bbox } + + public var geometry: AnyGeometry? + public var properties: Properties + + public init(geometry: AnyGeometry?, properties: Properties) { + self.geometry = geometry + self.properties = properties + } + +} diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift new file mode 100644 index 0000000..8ce90fc --- /dev/null +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -0,0 +1,21 @@ +// +// FeatureCollection.swift +// GeoSwift +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct FeatureCollection< +// BoundingBox: GeoJSON.BoundingBox, + Properties: GeoJSON.Properties +>: GeoJSON.Object { + + public static var geoJSONType: GeoJSON.`Type` { .featureCollection } + + public var features: [Feature] + + // FIXME: Fix bounding box + public var bbox: AnyBoundingBox? { nil } + +} diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift new file mode 100644 index 0000000..8f0b873 --- /dev/null +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -0,0 +1,75 @@ +// +// File.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import Turf + +public protocol Geometry: GeoJSON.Object, Hashable, Codable { + + static var geometryType: GeoJSON.`Type`.Geometry { get } + + var bbox: BoundingBox? { get } + + var asAnyGeometry: AnyGeometry { get } + +} + +extension Geometry { + + public static var geoJSONType: GeoJSON.`Type` { .geometry(Self.geometryType) } + +} + +public protocol SingleGeometry: Geometry { + + 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 } + +} + +public enum AnyGeometry: Hashable, Codable { + + case geometryCollection(GeometryCollection) + + case point2D(Point2D) + case multiPoint2D(MultiPoint2D) + case lineString2D(LineString2D) + case multiLineString2D(MultiLineString2D) + case polygon2D(Polygon2D) + case multiPolygon2D(MultiPolygon2D) + + public var bbox: AnyBoundingBox? { + switch self { + case .geometryCollection(let geometryCollection): + return geometryCollection.bbox + + case .point2D(let point2D): + return point2D.bbox?.asAny + case .multiPoint2D(let multiPoint2D): + return multiPoint2D.bbox?.asAny + case .lineString2D(let lineString2D): + return lineString2D.bbox?.asAny + case .multiLineString2D(let multiLineString2D): + return multiLineString2D.bbox?.asAny + case .polygon2D(let polygon2D): + return polygon2D.bbox?.asAny + case .multiPolygon2D(let multiPolygon2D): + return multiPolygon2D.bbox?.asAny + } + } + +} diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift new file mode 100644 index 0000000..5a812c3 --- /dev/null +++ b/Sources/GeoJSON/Objects/Object.swift @@ -0,0 +1,17 @@ +// +// Object.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public protocol Object { + + associatedtype BoundingBox: GeoJSON.BoundingBox + + static var geoJSONType: GeoJSON.`Type` { get } + + var bbox: BoundingBox? { get } + +} diff --git a/Sources/GeoJSON/Position.swift b/Sources/GeoJSON/Position.swift new file mode 100644 index 0000000..ca9f474 --- /dev/null +++ b/Sources/GeoJSON/Position.swift @@ -0,0 +1,16 @@ +// +// Position.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoModels +import Turf + +public protocol Position: Hashable, Boundable {} + +public typealias Position2D = Coordinate2D + +extension Position2D: Position {} diff --git a/Sources/GeoJSON/Properties.swift b/Sources/GeoJSON/Properties.swift new file mode 100644 index 0000000..a7833ac --- /dev/null +++ b/Sources/GeoJSON/Properties.swift @@ -0,0 +1,9 @@ +// +// Properties.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public typealias Properties = Codable & Hashable diff --git a/Sources/GeoJSON/Type.swift b/Sources/GeoJSON/Type.swift new file mode 100644 index 0000000..efd217a --- /dev/null +++ b/Sources/GeoJSON/Type.swift @@ -0,0 +1,51 @@ +// +// Type.swift +// GeoSwift +// +// Created by Rémi Bardon on 07/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public enum `Type`: Hashable, Codable, RawRepresentable { + + 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/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift new file mode 100644 index 0000000..664a577 --- /dev/null +++ b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift @@ -0,0 +1,180 @@ +// +// 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 { + + 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.self, from: data) + + let expected: Feature = Feature( + geometry: .point2D(Point2D(coordinates: .nantes)), + properties: FeatureProperties() + ) + XCTAssertEqual(feature, expected) + } + +} diff --git a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift new file mode 100644 index 0000000..7369974 --- /dev/null +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -0,0 +1,166 @@ +// +// 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]", + "}", + ].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]", + "]", + "}", + ].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]", + "]", + "}", + ].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]", + "]", + "]", + "}", + ].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]", + "]", + "]", + "}", + ].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]", + "]", + "]", + "]", + "}", + ].joined()) + } + + func testFeature2DEncode() throws { + struct FeatureProperties: Hashable, Codable {} + + let feature: Feature = Feature( + geometry: .point2D(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]", + "}", + ].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) } + +}