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 01/10] =?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) } + +} From 1c941d2e932035005c68fa9677cc140c2711699b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Tue, 8 Feb 2022 18:29:56 +0100 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20Add=203D=20to=20GeoJSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/GeoJSON/BoundingBox.swift | 9 + Sources/GeoJSON/GeoJSON+Codable.swift | 161 ++++++++++++++++-- Sources/GeoJSON/Geometries/LineString.swift | 24 +++ .../GeoJSON/Geometries/MultiLineString.swift | 30 ++++ Sources/GeoJSON/Geometries/MultiPoint.swift | 16 ++ Sources/GeoJSON/Geometries/MultiPolygon.swift | 16 ++ Sources/GeoJSON/Geometries/Point.swift | 16 ++ Sources/GeoJSON/Geometries/Polygon.swift | 19 ++- Sources/GeoJSON/Objects/Geometry.swift | 20 +++ Sources/GeoJSON/Position.swift | 4 + 10 files changed, 296 insertions(+), 19 deletions(-) diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift index c6d971c..8c672e4 100644 --- a/Sources/GeoJSON/BoundingBox.swift +++ b/Sources/GeoJSON/BoundingBox.swift @@ -22,9 +22,18 @@ extension BoundingBox2D: BoundingBox { } +public typealias BoundingBox3D = GeoModels.BoundingBox3D + +extension BoundingBox3D: BoundingBox { + + public var asAny: AnyBoundingBox { .threeDimensions(self) } + +} + public enum AnyBoundingBox: BoundingBox, Hashable, Codable { case twoDimensions(BoundingBox2D) + case threeDimensions(BoundingBox3D) public var asAny: AnyBoundingBox { self } diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index a5b034d..906fdda 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -65,6 +65,24 @@ extension Longitude: Codable { } +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 { @@ -85,6 +103,28 @@ extension Position2D: Codable { } +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 { @@ -147,34 +187,59 @@ extension AnyGeometry { let container = try decoder.singleValueContainer() - // FIXME: Fix 2D/3D performance by checking the number of values in `bbox` + // 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 { + 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) + do { + let multiPoint3D = try container.decode(MultiPoint3D.self) + self = .multiPoint3D(multiPoint3D) + } catch { + let multiPoint2D = try container.decode(MultiPoint2D.self) + self = .multiPoint2D(multiPoint2D) + } case .lineString: - let lineString2D = try container.decode(LineString2D.self) - self = .lineString2D(lineString2D) + do { + let lineString3D = try container.decode(LineString3D.self) + self = .lineString3D(lineString3D) + } catch { + let lineString2D = try container.decode(LineString2D.self) + self = .lineString2D(lineString2D) + } case .multiLineString: - let multiLineString2D = try container.decode(MultiLineString2D.self) - self = .multiLineString2D(multiLineString2D) + do { + let multiLineString3D = try container.decode(MultiLineString3D.self) + self = .multiLineString3D(multiLineString3D) + } catch { + let multiLineString2D = try container.decode(MultiLineString2D.self) + self = .multiLineString2D(multiLineString2D) + } case .polygon: - let polygon2D = try container.decode(Polygon2D.self) - self = .polygon2D(polygon2D) + do { + let polygon3D = try container.decode(Polygon3D.self) + self = .polygon3D(polygon3D) + } catch { + let polygon2D = try container.decode(Polygon2D.self) + self = .polygon2D(polygon2D) + } case .multiPolygon: - let multiPolygon2D = try container.decode(MultiPolygon2D.self) - self = .multiPolygon2D(multiPolygon2D) + do { + let multiPolygon3D = try container.decode(MultiPolygon3D.self) + self = .multiPolygon3D(multiPolygon3D) + } catch { + let multiPolygon2D = try container.decode(MultiPolygon2D.self) + self = .multiPolygon2D(multiPolygon2D) + } } } @@ -184,6 +249,7 @@ extension AnyGeometry { switch self { case .geometryCollection(let geometryCollection): try container.encode(geometryCollection) + case .point2D(let point2D): try container.encode(point2D) case .multiPoint2D(let multiPoint2D): @@ -196,6 +262,19 @@ extension AnyGeometry { 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) } } @@ -228,13 +307,57 @@ extension BoundingBox2D { } +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() - let boundingBox2D = try container.decode(BoundingBox2D.self) - self = .twoDimensions(boundingBox2D) + 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 { @@ -243,6 +366,8 @@ extension AnyBoundingBox { switch self { case .twoDimensions(let boundingBox2D): try container.encode(boundingBox2D) + case .threeDimensions(let boundingBox3D): + try container.encode(boundingBox3D) } } diff --git a/Sources/GeoJSON/Geometries/LineString.swift b/Sources/GeoJSON/Geometries/LineString.swift index e1f9794..e3898d7 100644 --- a/Sources/GeoJSON/Geometries/LineString.swift +++ b/Sources/GeoJSON/Geometries/LineString.swift @@ -38,3 +38,27 @@ public struct LineString2D: LineString { } } + +public struct LineString3D: LineString { + + public typealias Position = Position3D + + public static var geometryType: GeoJSON.`Type`.Geometry { .lineString } + + 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 index 328ab12..bb0ef63 100644 --- a/Sources/GeoJSON/Geometries/MultiLineString.swift +++ b/Sources/GeoJSON/Geometries/MultiLineString.swift @@ -44,3 +44,33 @@ public struct MultiLineString2D: MultiLineString { } } + +public struct MultiLineString3D: MultiLineString { + + public typealias LineString = LineString3D + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiLineString } + + 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 index 70bfe88..cb5d9c6 100644 --- a/Sources/GeoJSON/Geometries/MultiPoint.swift +++ b/Sources/GeoJSON/Geometries/MultiPoint.swift @@ -28,3 +28,19 @@ public struct MultiPoint2D: MultiPoint { } } + +public struct MultiPoint3D: MultiPoint { + + public typealias Point = Point3D + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPoint } + + 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 index 24954b9..e665007 100644 --- a/Sources/GeoJSON/Geometries/MultiPolygon.swift +++ b/Sources/GeoJSON/Geometries/MultiPolygon.swift @@ -28,3 +28,19 @@ public struct MultiPolygon2D: MultiPolygon { } } + +public struct MultiPolygon3D: MultiPolygon { + + public typealias Polygon = Polygon3D + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPolygon } + + 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 index 7c6c173..f87a7dc 100644 --- a/Sources/GeoJSON/Geometries/Point.swift +++ b/Sources/GeoJSON/Geometries/Point.swift @@ -28,3 +28,19 @@ public struct Point2D: Point { } } + +public struct Point3D: Point { + + public typealias Position = Position3D + + public static var geometryType: GeoJSON.`Type`.Geometry { .point } + + 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 index 3df2c59..8cb96b4 100644 --- a/Sources/GeoJSON/Geometries/Polygon.swift +++ b/Sources/GeoJSON/Geometries/Polygon.swift @@ -55,7 +55,7 @@ extension LinearRingCoordinates: ExpressibleByArrayLiteral { } -/// A polygon / linear ring. +/// A 2D polygon / linear ring. public struct Polygon2D: Polygon { public typealias Point = Point2D @@ -71,3 +71,20 @@ public struct Polygon2D: Polygon { } } + +/// A 3D polygon / linear ring. +public struct Polygon3D: Polygon { + + public typealias Point = Point3D + + public static var geometryType: GeoJSON.`Type`.Geometry { .polygon } + + public var coordinates: Coordinates + + public var asAnyGeometry: AnyGeometry { .polygon3D(self) } + + public init(coordinates: Coordinates) { + self.coordinates = coordinates + } + +} diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift index 8f0b873..58b7e98 100644 --- a/Sources/GeoJSON/Objects/Geometry.swift +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -52,6 +52,13 @@ public enum AnyGeometry: Hashable, Codable { 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 bbox: AnyBoundingBox? { switch self { case .geometryCollection(let geometryCollection): @@ -69,6 +76,19 @@ public enum AnyGeometry: Hashable, Codable { return polygon2D.bbox?.asAny case .multiPolygon2D(let multiPolygon2D): return multiPolygon2D.bbox?.asAny + + case .point3D(let point3D): + return point3D.bbox?.asAny + case .multiPoint3D(let multiPoint3D): + return multiPoint3D.bbox?.asAny + case .lineString3D(let lineString3D): + return lineString3D.bbox?.asAny + case .multiLineString3D(let multiLineString3D): + return multiLineString3D.bbox?.asAny + case .polygon3D(let polygon3D): + return polygon3D.bbox?.asAny + case .multiPolygon3D(let multiPolygon3D): + return multiPolygon3D.bbox?.asAny } } diff --git a/Sources/GeoJSON/Position.swift b/Sources/GeoJSON/Position.swift index ca9f474..0542a28 100644 --- a/Sources/GeoJSON/Position.swift +++ b/Sources/GeoJSON/Position.swift @@ -14,3 +14,7 @@ public protocol Position: Hashable, Boundable {} public typealias Position2D = Coordinate2D extension Position2D: Position {} + +public typealias Position3D = Coordinate3D + +extension Position3D: Position {} From 4d811201f64c6b6e4a27b73266ffd60cbde99ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Wed, 9 Feb 2022 10:29:05 +0100 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=85=20Add=20real-world=20use=20case?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/GeoJSON/GeoJSON+Codable.swift | 35 ++++ Sources/GeoJSON/Objects/Feature.swift | 2 +- Sources/GeoJSON/Objects/Geometry.swift | 2 +- Sources/GeoJSON/Objects/Object.swift | 2 +- .../GeoJSONTests/GeoJSON+DecodableTests.swift | 190 ++++++++++++++++++ 5 files changed, 228 insertions(+), 3 deletions(-) diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index 906fdda..d35cb5a 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -409,3 +409,38 @@ extension Feature { } } + +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].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 + // TODO: Memoize bboxes not to recompute them all the time (bboxes tree) + try container.encodeIfPresent(self.bbox, forKey: .bbox) + } + +} diff --git a/Sources/GeoJSON/Objects/Feature.swift b/Sources/GeoJSON/Objects/Feature.swift index c8f1d90..ecdd7ed 100644 --- a/Sources/GeoJSON/Objects/Feature.swift +++ b/Sources/GeoJSON/Objects/Feature.swift @@ -10,7 +10,7 @@ public struct Feature< // Geometry: GeoJSON.Geometry, // BoundingBox: GeoJSON.BoundingBox, Properties: GeoJSON.Properties ->: GeoJSON.Object, Hashable, Codable { +>: GeoJSON.Object { public static var geoJSONType: GeoJSON.`Type` { .feature } diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift index 58b7e98..4cd9328 100644 --- a/Sources/GeoJSON/Objects/Geometry.swift +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -8,7 +8,7 @@ import Turf -public protocol Geometry: GeoJSON.Object, Hashable, Codable { +public protocol Geometry: GeoJSON.Object { static var geometryType: GeoJSON.`Type`.Geometry { get } diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift index 5a812c3..d3e9138 100644 --- a/Sources/GeoJSON/Objects/Object.swift +++ b/Sources/GeoJSON/Objects/Object.swift @@ -6,7 +6,7 @@ // Copyright © 2022 Rémi Bardon. All rights reserved. // -public protocol Object { +public protocol Object: Hashable, Codable { associatedtype BoundingBox: GeoJSON.BoundingBox diff --git a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift index 664a577..0800c02 100644 --- a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift @@ -11,6 +11,8 @@ 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)) @@ -177,4 +179,192 @@ final class GeoJSONDecodableTests: XCTestCase { 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(Feature.self, from: data) + + let expected: Feature = Feature( + 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(FeatureCollection.self, from: data) + + let expected: FeatureCollection = FeatureCollection(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) + } + } From 33f9ca1884fe2ebaa3defc9400fae8a82d436215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Wed, 9 Feb 2022 15:30:45 +0100 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=93=96=20Add=20documentation=20to?= =?UTF-8?q?=20`GeoJSON`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/GeoJSON/BoundingBox.swift | 5 ++ Sources/GeoJSON/Errors.swift | 2 + Sources/GeoJSON/FeatureProperties.swift | 10 ++++ Sources/GeoJSON/GeoJSON.docc/Documentation.md | 27 ++++++++++ Sources/GeoJSON/GeoJSON.docc/Geometries.md | 54 +++++++++++++++++++ Sources/GeoJSON/GeoJSON.docc/Types.md | 20 +++++++ .../Geometries/GeometryCollection.swift | 3 +- Sources/GeoJSON/Geometries/LineString.swift | 3 ++ .../GeoJSON/Geometries/MultiLineString.swift | 3 ++ Sources/GeoJSON/Geometries/MultiPoint.swift | 3 ++ Sources/GeoJSON/Geometries/MultiPolygon.swift | 3 ++ Sources/GeoJSON/Geometries/Point.swift | 3 ++ Sources/GeoJSON/Geometries/Polygon.swift | 5 +- Sources/GeoJSON/Objects/Feature.swift | 3 +- .../GeoJSON/Objects/FeatureCollection.swift | 3 +- Sources/GeoJSON/Objects/Geometry.swift | 6 +++ Sources/GeoJSON/Objects/Object.swift | 3 ++ Sources/GeoJSON/Position.swift | 3 ++ Sources/GeoJSON/Properties.swift | 9 ---- Sources/GeoJSON/Type.swift | 2 + 20 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 Sources/GeoJSON/FeatureProperties.swift create mode 100644 Sources/GeoJSON/GeoJSON.docc/Documentation.md create mode 100644 Sources/GeoJSON/GeoJSON.docc/Geometries.md create mode 100644 Sources/GeoJSON/GeoJSON.docc/Types.md delete mode 100644 Sources/GeoJSON/Properties.swift diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift index 8c672e4..52f44fc 100644 --- a/Sources/GeoJSON/BoundingBox.swift +++ b/Sources/GeoJSON/BoundingBox.swift @@ -8,12 +8,15 @@ import GeoModels +/// A [GeoJSON Bounding Box](https://datatracker.ietf.org/doc/html/rfc7946#section-5). public protocol BoundingBox: Hashable, Codable { + /// This bonding box, but type-erased. var asAny: AnyBoundingBox { get } } +/// A two-dimensional ``BoundingBox``. public typealias BoundingBox2D = GeoModels.BoundingBox2D extension BoundingBox2D: BoundingBox { @@ -22,6 +25,7 @@ extension BoundingBox2D: BoundingBox { } +/// A three-dimensional ``BoundingBox``. public typealias BoundingBox3D = GeoModels.BoundingBox3D extension BoundingBox3D: BoundingBox { @@ -30,6 +34,7 @@ extension BoundingBox3D: BoundingBox { } +/// A type-erased ``BoundingBox``. public enum AnyBoundingBox: BoundingBox, Hashable, Codable { case twoDimensions(BoundingBox2D) diff --git a/Sources/GeoJSON/Errors.swift b/Sources/GeoJSON/Errors.swift index bb1e2b3..8b240b5 100644 --- a/Sources/GeoJSON/Errors.swift +++ b/Sources/GeoJSON/Errors.swift @@ -8,6 +8,8 @@ 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 diff --git a/Sources/GeoJSON/FeatureProperties.swift b/Sources/GeoJSON/FeatureProperties.swift new file mode 100644 index 0000000..3c8a595 --- /dev/null +++ b/Sources/GeoJSON/FeatureProperties.swift @@ -0,0 +1,10 @@ +// +// FeatureProperties.swift +// GeoSwift +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +/// The `"properties"` field of a [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). +public typealias FeatureProperties = Codable & Hashable diff --git a/Sources/GeoJSON/GeoJSON.docc/Documentation.md b/Sources/GeoJSON/GeoJSON.docc/Documentation.md new file mode 100644 index 0000000..6030082 --- /dev/null +++ b/Sources/GeoJSON/GeoJSON.docc/Documentation.md @@ -0,0 +1,27 @@ +# ``GeoJSON`` + +## Topics + +### Positions + +- ``Position`` +- ``Position2D`` +- ``Position3D`` + +### Types + +- + +### Objects + +- ``Object`` +- +- ``Feature`` +- ``FeatureCollection`` + +### 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..0b96368 --- /dev/null +++ b/Sources/GeoJSON/GeoJSON.docc/Geometries.md @@ -0,0 +1,54 @@ +# Geometries + +## Topics + +### Base + +- ``Geometry`` +- ``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 index 340d3fe..60b90d0 100644 --- a/Sources/GeoJSON/Geometries/GeometryCollection.swift +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -6,9 +6,10 @@ // 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: Geometry { - public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection} + public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection } public var bbox: AnyBoundingBox? diff --git a/Sources/GeoJSON/Geometries/LineString.swift b/Sources/GeoJSON/Geometries/LineString.swift index e3898d7..dd42337 100644 --- a/Sources/GeoJSON/Geometries/LineString.swift +++ b/Sources/GeoJSON/Geometries/LineString.swift @@ -8,6 +8,7 @@ import NonEmpty +/// A [GeoJSON LineString](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4). public protocol LineString: SingleGeometry { associatedtype Position: GeoJSON.Position @@ -15,6 +16,7 @@ public protocol LineString: SingleGeometry { } +/// A ``LineString`` with ``Point2D``s. public struct LineString2D: LineString { public typealias Position = Position2D @@ -39,6 +41,7 @@ public struct LineString2D: LineString { } +/// A ``LineString`` with ``Point3D``s. public struct LineString3D: LineString { public typealias Position = Position3D diff --git a/Sources/GeoJSON/Geometries/MultiLineString.swift b/Sources/GeoJSON/Geometries/MultiLineString.swift index bb0ef63..08469cf 100644 --- a/Sources/GeoJSON/Geometries/MultiLineString.swift +++ b/Sources/GeoJSON/Geometries/MultiLineString.swift @@ -8,6 +8,7 @@ import NonEmpty +/// A [GeoJSON MultiLineString](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.5). public protocol MultiLineString: SingleGeometry { associatedtype LineString: GeoJSON.LineString @@ -15,6 +16,7 @@ public protocol MultiLineString: SingleGeometry { } +/// A ``MultiLineString`` with ``Point2D``s. public struct MultiLineString2D: MultiLineString { public typealias LineString = LineString2D @@ -45,6 +47,7 @@ public struct MultiLineString2D: MultiLineString { } +/// A ``MultiLineString`` with ``Point3D``s. public struct MultiLineString3D: MultiLineString { public typealias LineString = LineString3D diff --git a/Sources/GeoJSON/Geometries/MultiPoint.swift b/Sources/GeoJSON/Geometries/MultiPoint.swift index cb5d9c6..5a1746d 100644 --- a/Sources/GeoJSON/Geometries/MultiPoint.swift +++ b/Sources/GeoJSON/Geometries/MultiPoint.swift @@ -6,6 +6,7 @@ // 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 @@ -13,6 +14,7 @@ public protocol MultiPoint: SingleGeometry { } +/// A ``MultiPoint`` with ``Point2D``s. public struct MultiPoint2D: MultiPoint { public typealias Point = Point2D @@ -29,6 +31,7 @@ public struct MultiPoint2D: MultiPoint { } +/// A ``MultiPoint`` with ``Point3D``s. public struct MultiPoint3D: MultiPoint { public typealias Point = Point3D diff --git a/Sources/GeoJSON/Geometries/MultiPolygon.swift b/Sources/GeoJSON/Geometries/MultiPolygon.swift index e665007..ac01a21 100644 --- a/Sources/GeoJSON/Geometries/MultiPolygon.swift +++ b/Sources/GeoJSON/Geometries/MultiPolygon.swift @@ -6,6 +6,7 @@ // 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 @@ -13,6 +14,7 @@ public protocol MultiPolygon: SingleGeometry { } +/// A ``MultiPolygon`` with ``Point2D``s. public struct MultiPolygon2D: MultiPolygon { public typealias Polygon = Polygon2D @@ -29,6 +31,7 @@ public struct MultiPolygon2D: MultiPolygon { } +/// A ``MultiPolygon`` with ``Point3D``s. public struct MultiPolygon3D: MultiPolygon { public typealias Polygon = Polygon3D diff --git a/Sources/GeoJSON/Geometries/Point.swift b/Sources/GeoJSON/Geometries/Point.swift index f87a7dc..228f361 100644 --- a/Sources/GeoJSON/Geometries/Point.swift +++ b/Sources/GeoJSON/Geometries/Point.swift @@ -6,6 +6,7 @@ // 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 @@ -13,6 +14,7 @@ public protocol Point: SingleGeometry { } +/// A two-dimensional ``Point`` (with longitude and latitude). public struct Point2D: Point { public typealias Position = Position2D @@ -29,6 +31,7 @@ public struct Point2D: Point { } +/// A three-dimensional ``Point`` (with longitude, latitude and altitude). public struct Point3D: Point { public typealias Position = Position3D diff --git a/Sources/GeoJSON/Geometries/Polygon.swift b/Sources/GeoJSON/Geometries/Polygon.swift index 8cb96b4..bee3fae 100644 --- a/Sources/GeoJSON/Geometries/Polygon.swift +++ b/Sources/GeoJSON/Geometries/Polygon.swift @@ -9,6 +9,7 @@ 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 @@ -55,7 +56,7 @@ extension LinearRingCoordinates: ExpressibleByArrayLiteral { } -/// A 2D polygon / linear ring. +/// A ``Polygon`` with ``Point2D``s. public struct Polygon2D: Polygon { public typealias Point = Point2D @@ -72,7 +73,7 @@ public struct Polygon2D: Polygon { } -/// A 3D polygon / linear ring. +/// A ``Polygon`` with ``Point3D``s. public struct Polygon3D: Polygon { public typealias Point = Point3D diff --git a/Sources/GeoJSON/Objects/Feature.swift b/Sources/GeoJSON/Objects/Feature.swift index ecdd7ed..df1b5fa 100644 --- a/Sources/GeoJSON/Objects/Feature.swift +++ b/Sources/GeoJSON/Objects/Feature.swift @@ -6,10 +6,11 @@ // Copyright © 2022 Rémi Bardon. All rights reserved. // +/// A [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). public struct Feature< // Geometry: GeoJSON.Geometry, // BoundingBox: GeoJSON.BoundingBox, - Properties: GeoJSON.Properties + Properties: GeoJSON.FeatureProperties >: GeoJSON.Object { public static var geoJSONType: GeoJSON.`Type` { .feature } diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift index 8ce90fc..28f4818 100644 --- a/Sources/GeoJSON/Objects/FeatureCollection.swift +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -6,9 +6,10 @@ // Copyright © 2022 Rémi Bardon. All rights reserved. // +/// A [GeoJSON FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3). public struct FeatureCollection< // BoundingBox: GeoJSON.BoundingBox, - Properties: GeoJSON.Properties + Properties: GeoJSON.FeatureProperties >: GeoJSON.Object { public static var geoJSONType: GeoJSON.`Type` { .featureCollection } diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift index 4cd9328..6344c2b 100644 --- a/Sources/GeoJSON/Objects/Geometry.swift +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -8,12 +8,15 @@ import Turf +/// A [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1). public protocol Geometry: GeoJSON.Object { + /// The GeoJSON type of this geometry. static var geometryType: GeoJSON.`Type`.Geometry { get } var bbox: BoundingBox? { get } + /// This geometry, but type-erased. var asAnyGeometry: AnyGeometry { get } } @@ -24,6 +27,8 @@ extension Geometry { } +/// 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: Geometry { associatedtype Coordinates: Boundable & Hashable & Codable @@ -41,6 +46,7 @@ extension SingleGeometry { } +/// A type-erased ``Geometry``. public enum AnyGeometry: Hashable, Codable { case geometryCollection(GeometryCollection) diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift index d3e9138..61406e4 100644 --- a/Sources/GeoJSON/Objects/Object.swift +++ b/Sources/GeoJSON/Objects/Object.swift @@ -6,10 +6,13 @@ // Copyright © 2022 Rémi Bardon. All rights reserved. // +/// A [GeoJSON Object](https://datatracker.ietf.org/doc/html/rfc7946#section-3). public protocol Object: Hashable, Codable { associatedtype BoundingBox: GeoJSON.BoundingBox + /// 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 } var bbox: BoundingBox? { get } diff --git a/Sources/GeoJSON/Position.swift b/Sources/GeoJSON/Position.swift index 0542a28..b1401fb 100644 --- a/Sources/GeoJSON/Position.swift +++ b/Sources/GeoJSON/Position.swift @@ -9,12 +9,15 @@ import GeoModels import Turf +/// A [GeoJSON Position](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1). public protocol Position: Hashable, 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/Properties.swift b/Sources/GeoJSON/Properties.swift deleted file mode 100644 index a7833ac..0000000 --- a/Sources/GeoJSON/Properties.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// 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 index efd217a..d6ee41d 100644 --- a/Sources/GeoJSON/Type.swift +++ b/Sources/GeoJSON/Type.swift @@ -6,8 +6,10 @@ // 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" From fa98c9272ba5c7efdf370d4efe3dd866cfca2ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Wed, 9 Feb 2022 16:25:34 +0100 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8=20Make=20`Feature`=20generic=20?= =?UTF-8?q?over=20`Geometry`=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/GeoJSON/GeoJSON+Codable.swift | 7 +- Sources/GeoJSON/GeoJSON.docc/Documentation.md | 3 + Sources/GeoJSON/GeoJSON.docc/Geometries.md | 1 + .../Geometries/GeometryCollection.swift | 2 +- Sources/GeoJSON/Geometries/LineString.swift | 10 ++- .../GeoJSON/Geometries/MultiLineString.swift | 10 ++- Sources/GeoJSON/Geometries/MultiPoint.swift | 10 ++- Sources/GeoJSON/Geometries/MultiPolygon.swift | 10 ++- Sources/GeoJSON/Geometries/Point.swift | 10 ++- Sources/GeoJSON/Geometries/Polygon.swift | 10 ++- Sources/GeoJSON/Objects/Feature.swift | 14 ++-- .../GeoJSON/Objects/FeatureCollection.swift | 11 ++- Sources/GeoJSON/Objects/Geometry.swift | 77 +++++++++++-------- Sources/GeoJSON/Objects/Object.swift | 10 ++- .../GeoJSONTests/GeoJSON+DecodableTests.swift | 12 +-- .../GeoJSONTests/GeoJSON+EncodableTests.swift | 2 +- 16 files changed, 121 insertions(+), 78 deletions(-) diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index d35cb5a..f86627b 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -391,7 +391,7 @@ extension Feature { )) } - let geometry = try container.decodeIfPresent(AnyGeometry.self, forKey: .geometry) + let geometry = try container.decodeIfPresent(Geometry.self, forKey: .geometry) let properties = try container.decode(Properties.self, forKey: .properties) self.init(geometry: geometry, properties: properties) @@ -428,7 +428,10 @@ extension FeatureCollection { )) } - let features = try container.decodeIfPresent([Feature].self, forKey: .features) ?? [] + let features = try container.decodeIfPresent( + [Feature].self, + forKey: .features + ) ?? [] self.init(features: features) } diff --git a/Sources/GeoJSON/GeoJSON.docc/Documentation.md b/Sources/GeoJSON/GeoJSON.docc/Documentation.md index 6030082..769ce58 100644 --- a/Sources/GeoJSON/GeoJSON.docc/Documentation.md +++ b/Sources/GeoJSON/GeoJSON.docc/Documentation.md @@ -15,9 +15,12 @@ ### Objects - ``Object`` +- ``CodableObject`` - - ``Feature`` +- ``AnyFeature`` - ``FeatureCollection`` +- ``AnyFeatureCollection`` ### Bounding Boxes diff --git a/Sources/GeoJSON/GeoJSON.docc/Geometries.md b/Sources/GeoJSON/GeoJSON.docc/Geometries.md index 0b96368..ec387f0 100644 --- a/Sources/GeoJSON/GeoJSON.docc/Geometries.md +++ b/Sources/GeoJSON/GeoJSON.docc/Geometries.md @@ -5,6 +5,7 @@ ### Base - ``Geometry`` +- ``CodableGeometry`` - ``AnyGeometry`` ### Introduced concepts diff --git a/Sources/GeoJSON/Geometries/GeometryCollection.swift b/Sources/GeoJSON/Geometries/GeometryCollection.swift index 60b90d0..5b3070a 100644 --- a/Sources/GeoJSON/Geometries/GeometryCollection.swift +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -7,7 +7,7 @@ // /// A [GeoJSON GeometryCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8). -public struct GeometryCollection: Geometry { +public struct GeometryCollection: CodableGeometry { public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection } diff --git a/Sources/GeoJSON/Geometries/LineString.swift b/Sources/GeoJSON/Geometries/LineString.swift index dd42337..1659814 100644 --- a/Sources/GeoJSON/Geometries/LineString.swift +++ b/Sources/GeoJSON/Geometries/LineString.swift @@ -16,13 +16,17 @@ public protocol LineString: SingleGeometry { } +extension LineString { + + public static var geometryType: GeoJSON.`Type`.Geometry { .lineString } + +} + /// A ``LineString`` with ``Point2D``s. 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) } @@ -46,8 +50,6 @@ public struct LineString3D: LineString { public typealias Position = Position3D - public static var geometryType: GeoJSON.`Type`.Geometry { .lineString } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .lineString3D(self) } diff --git a/Sources/GeoJSON/Geometries/MultiLineString.swift b/Sources/GeoJSON/Geometries/MultiLineString.swift index 08469cf..1eb6f8c 100644 --- a/Sources/GeoJSON/Geometries/MultiLineString.swift +++ b/Sources/GeoJSON/Geometries/MultiLineString.swift @@ -16,13 +16,17 @@ public protocol MultiLineString: SingleGeometry { } +extension MultiLineString { + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiLineString } + +} + /// A ``MultiLineString`` with ``Point2D``s. 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) } @@ -52,8 +56,6 @@ public struct MultiLineString3D: MultiLineString { public typealias LineString = LineString3D - public static var geometryType: GeoJSON.`Type`.Geometry { .multiLineString } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .multiLineString3D(self) } diff --git a/Sources/GeoJSON/Geometries/MultiPoint.swift b/Sources/GeoJSON/Geometries/MultiPoint.swift index 5a1746d..381c87c 100644 --- a/Sources/GeoJSON/Geometries/MultiPoint.swift +++ b/Sources/GeoJSON/Geometries/MultiPoint.swift @@ -14,13 +14,17 @@ public protocol MultiPoint: SingleGeometry { } +extension MultiPoint { + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPoint } + +} + /// A ``MultiPoint`` with ``Point2D``s. 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) } @@ -36,8 +40,6 @@ public struct MultiPoint3D: MultiPoint { public typealias Point = Point3D - public static var geometryType: GeoJSON.`Type`.Geometry { .multiPoint } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .multiPoint3D(self) } diff --git a/Sources/GeoJSON/Geometries/MultiPolygon.swift b/Sources/GeoJSON/Geometries/MultiPolygon.swift index ac01a21..bb477d2 100644 --- a/Sources/GeoJSON/Geometries/MultiPolygon.swift +++ b/Sources/GeoJSON/Geometries/MultiPolygon.swift @@ -14,13 +14,17 @@ public protocol MultiPolygon: SingleGeometry { } +extension MultiPolygon { + + public static var geometryType: GeoJSON.`Type`.Geometry { .multiPolygon } + +} + /// A ``MultiPolygon`` with ``Point2D``s. 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) } @@ -36,8 +40,6 @@ public struct MultiPolygon3D: MultiPolygon { public typealias Polygon = Polygon3D - public static var geometryType: GeoJSON.`Type`.Geometry { .multiPolygon } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .multiPolygon3D(self) } diff --git a/Sources/GeoJSON/Geometries/Point.swift b/Sources/GeoJSON/Geometries/Point.swift index 228f361..968d013 100644 --- a/Sources/GeoJSON/Geometries/Point.swift +++ b/Sources/GeoJSON/Geometries/Point.swift @@ -14,13 +14,17 @@ public protocol Point: SingleGeometry { } +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 static var geometryType: GeoJSON.`Type`.Geometry { .point } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .point2D(self) } @@ -36,8 +40,6 @@ public struct Point3D: Point { public typealias Position = Position3D - public static var geometryType: GeoJSON.`Type`.Geometry { .point } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .point3D(self) } diff --git a/Sources/GeoJSON/Geometries/Polygon.swift b/Sources/GeoJSON/Geometries/Polygon.swift index bee3fae..d74dd81 100644 --- a/Sources/GeoJSON/Geometries/Polygon.swift +++ b/Sources/GeoJSON/Geometries/Polygon.swift @@ -17,6 +17,12 @@ public protocol Polygon: SingleGeometry { } +extension Polygon { + + public static var geometryType: GeoJSON.`Type`.Geometry { .polygon } + +} + public struct LinearRingCoordinates: Boundable, Hashable, Codable { public typealias RawValue = NonEmpty>>> @@ -61,8 +67,6 @@ 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) } @@ -78,8 +82,6 @@ public struct Polygon3D: Polygon { public typealias Point = Point3D - public static var geometryType: GeoJSON.`Type`.Geometry { .polygon } - public var coordinates: Coordinates public var asAnyGeometry: AnyGeometry { .polygon3D(self) } diff --git a/Sources/GeoJSON/Objects/Feature.swift b/Sources/GeoJSON/Objects/Feature.swift index df1b5fa..47464b3 100644 --- a/Sources/GeoJSON/Objects/Feature.swift +++ b/Sources/GeoJSON/Objects/Feature.swift @@ -8,21 +8,23 @@ /// A [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). public struct Feature< -// Geometry: GeoJSON.Geometry, -// BoundingBox: GeoJSON.BoundingBox, + Geometry: GeoJSON.Geometry & Codable, Properties: GeoJSON.FeatureProperties ->: GeoJSON.Object { +>: CodableObject { public static var geoJSONType: GeoJSON.`Type` { .feature } - public var bbox: AnyBoundingBox? { geometry?.bbox } + public var bbox: Geometry.BoundingBox? { geometry?.bbox } - public var geometry: AnyGeometry? + public var geometry: Geometry? public var properties: Properties - public init(geometry: AnyGeometry?, properties: Properties) { + public init(geometry: Geometry?, properties: Properties) { self.geometry = geometry self.properties = properties } } + +/// A (half) type-erased ``Feature``. +public typealias AnyFeature = Feature diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift index 28f4818..51fba32 100644 --- a/Sources/GeoJSON/Objects/FeatureCollection.swift +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -8,15 +8,20 @@ /// A [GeoJSON FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3). public struct FeatureCollection< -// BoundingBox: GeoJSON.BoundingBox, + Geometry: GeoJSON.Geometry & Codable, Properties: GeoJSON.FeatureProperties ->: GeoJSON.Object { +>: CodableObject { public static var geoJSONType: GeoJSON.`Type` { .featureCollection } - public var features: [Feature] + public var features: [Feature] // FIXME: Fix bounding box public var bbox: AnyBoundingBox? { nil } } + +/// A (half) type-erased ``FeatureCollection``. +public typealias AnyFeatureCollection< + Properties: GeoJSON.FeatureProperties +> = FeatureCollection diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift index 6344c2b..29be8a9 100644 --- a/Sources/GeoJSON/Objects/Geometry.swift +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -11,9 +11,6 @@ import Turf /// A [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1). public protocol Geometry: GeoJSON.Object { - /// The GeoJSON type of this geometry. - static var geometryType: GeoJSON.`Type`.Geometry { get } - var bbox: BoundingBox? { get } /// This geometry, but type-erased. @@ -21,7 +18,14 @@ public protocol Geometry: GeoJSON.Object { } -extension Geometry { +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) } @@ -29,7 +33,7 @@ extension Geometry { /// 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: Geometry { +public protocol SingleGeometry: CodableGeometry { associatedtype Coordinates: Boundable & Hashable & Codable associatedtype BoundingBox = Coordinates.BoundingBox @@ -47,7 +51,7 @@ extension SingleGeometry { } /// A type-erased ``Geometry``. -public enum AnyGeometry: Hashable, Codable { +public enum AnyGeometry: Geometry, Hashable, Codable { case geometryCollection(GeometryCollection) @@ -65,37 +69,46 @@ public enum AnyGeometry: Hashable, Codable { 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 geometryCollection): - return geometryCollection.bbox + case .geometryCollection(let geo): return geo.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 + 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 point3D): - return point3D.bbox?.asAny - case .multiPoint3D(let multiPoint3D): - return multiPoint3D.bbox?.asAny - case .lineString3D(let lineString3D): - return lineString3D.bbox?.asAny - case .multiLineString3D(let multiLineString3D): - return multiLineString3D.bbox?.asAny - case .polygon3D(let polygon3D): - return polygon3D.bbox?.asAny - case .multiPolygon3D(let multiPolygon3D): - return multiPolygon3D.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 index 61406e4..7ff4878 100644 --- a/Sources/GeoJSON/Objects/Object.swift +++ b/Sources/GeoJSON/Objects/Object.swift @@ -7,14 +7,18 @@ // /// A [GeoJSON Object](https://datatracker.ietf.org/doc/html/rfc7946#section-3). -public protocol Object: Hashable, Codable { +public protocol Object: Hashable { associatedtype BoundingBox: GeoJSON.BoundingBox + var bbox: BoundingBox? { get } + +} + +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 } - var bbox: BoundingBox? { get } - } diff --git a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift index 0800c02..a13a368 100644 --- a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift @@ -170,10 +170,10 @@ final class GeoJSONDecodableTests: XCTestCase { ].joined() let data: Data = try XCTUnwrap(string.data(using: .utf8)) - let feature = try JSONDecoder().decode(Feature.self, from: data) + let feature = try JSONDecoder().decode(Feature.self, from: data) let expected: Feature = Feature( - geometry: .point2D(Point2D(coordinates: .nantes)), + geometry: Point2D(coordinates: .nantes), properties: FeatureProperties() ) XCTAssertEqual(feature, expected) @@ -200,9 +200,9 @@ final class GeoJSONDecodableTests: XCTestCase { """ let data: Data = try XCTUnwrap(string.data(using: .utf8)) - let feature = try JSONDecoder().decode(Feature.self, from: data) + let feature = try JSONDecoder().decode(AnyFeature.self, from: data) - let expected: Feature = Feature( + let expected: AnyFeature = AnyFeature( geometry: .point2D(Point2D(coordinates: .init(latitude: 0.5, longitude: 102))), properties: RealWorldProperties(prop0: "value0") ) @@ -337,9 +337,9 @@ final class GeoJSONDecodableTests: XCTestCase { """ let data: Data = try XCTUnwrap(string.data(using: .utf8)) - let feature = try JSONDecoder().decode(FeatureCollection.self, from: data) + let feature = try JSONDecoder().decode(AnyFeatureCollection.self, from: data) - let expected: FeatureCollection = FeatureCollection(features: [ + let expected: AnyFeatureCollection = AnyFeatureCollection(features: [ Feature( geometry: .point2D(Point2D(coordinates: .init(latitude: 0.5, longitude: 102))), properties: .type1(.init(prop0: "value0")) diff --git a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift index 7369974..3f9b7d4 100644 --- a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -142,7 +142,7 @@ final class GeoJSONEncodableTests: XCTestCase { struct FeatureProperties: Hashable, Codable {} let feature: Feature = Feature( - geometry: .point2D(Point2D(coordinates: .nantes)), + geometry: Point2D(coordinates: .nantes), properties: FeatureProperties() ) let data: Data = try JSONEncoder().encode(feature) From 3fc8191e6ef693861f0755a972e9bed96bfb2124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Wed, 9 Feb 2022 20:57:23 +0100 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8=20Add=20`"id"`=20to=20`Feature`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/GeoJSON/FeatureProperties.swift | 10 ------ Sources/GeoJSON/GeoJSON+Codable.swift | 9 +++-- Sources/GeoJSON/Helpers/NonID.swift | 9 +++++ Sources/GeoJSON/Objects/Feature.swift | 36 +++++++++++++++++-- .../GeoJSON/Objects/FeatureCollection.swift | 25 ++++++++++--- Sources/GeoJSON/Objects/Geometry.swift | 2 +- Sources/GeoJSON/Objects/Object.swift | 2 +- .../GeoJSONTests/GeoJSON+DecodableTests.swift | 29 ++++++++++++++- .../GeoJSONTests/GeoJSON+EncodableTests.swift | 28 +++++++++++++++ 9 files changed, 127 insertions(+), 23 deletions(-) delete mode 100644 Sources/GeoJSON/FeatureProperties.swift create mode 100644 Sources/GeoJSON/Helpers/NonID.swift diff --git a/Sources/GeoJSON/FeatureProperties.swift b/Sources/GeoJSON/FeatureProperties.swift deleted file mode 100644 index 3c8a595..0000000 --- a/Sources/GeoJSON/FeatureProperties.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// FeatureProperties.swift -// GeoSwift -// -// Created by Rémi Bardon on 04/02/2022. -// Copyright © 2022 Rémi Bardon. All rights reserved. -// - -/// The `"properties"` field of a [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). -public typealias FeatureProperties = Codable & Hashable diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index f86627b..530f7c2 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -375,12 +375,13 @@ extension AnyBoundingBox { fileprivate enum FeatureCodingKeys: String, CodingKey { case geoJSONType = "type" - case geometry, properties, bbox + 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) @@ -391,16 +392,18 @@ extension Feature { )) } + 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(geometry: geometry, properties: 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 @@ -429,7 +432,7 @@ extension FeatureCollection { } let features = try container.decodeIfPresent( - [Feature].self, + [Feature].self, forKey: .features ) ?? [] 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 index 47464b3..a076c27 100644 --- a/Sources/GeoJSON/Objects/Feature.swift +++ b/Sources/GeoJSON/Objects/Feature.swift @@ -8,23 +8,53 @@ /// A [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2). public struct Feature< + ID: Codable, Geometry: GeoJSON.Geometry & Codable, - Properties: GeoJSON.FeatureProperties + 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(geometry: Geometry?, 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 = Feature +public typealias AnyFeature = Feature diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift index 51fba32..46bd03c 100644 --- a/Sources/GeoJSON/Objects/FeatureCollection.swift +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -8,20 +8,37 @@ /// A [GeoJSON FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3). public struct FeatureCollection< + ID: Codable, Geometry: GeoJSON.Geometry & Codable, - Properties: GeoJSON.FeatureProperties + Properties: Codable >: CodableObject { public static var geoJSONType: GeoJSON.`Type` { .featureCollection } - public var features: [Feature] + public var features: [Feature] // FIXME: Fix bounding box public var bbox: AnyBoundingBox? { nil } } +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: GeoJSON.FeatureProperties -> = FeatureCollection + Properties: Codable +> = FeatureCollection diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift index 29be8a9..327109c 100644 --- a/Sources/GeoJSON/Objects/Geometry.swift +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -9,7 +9,7 @@ import Turf /// A [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1). -public protocol Geometry: GeoJSON.Object { +public protocol Geometry: GeoJSON.Object, Hashable { var bbox: BoundingBox? { get } diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift index 7ff4878..f0eec22 100644 --- a/Sources/GeoJSON/Objects/Object.swift +++ b/Sources/GeoJSON/Objects/Object.swift @@ -7,7 +7,7 @@ // /// A [GeoJSON Object](https://datatracker.ietf.org/doc/html/rfc7946#section-3). -public protocol Object: Hashable { +public protocol Object { associatedtype BoundingBox: GeoJSON.BoundingBox diff --git a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift index a13a368..bdbd869 100644 --- a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift @@ -170,7 +170,7 @@ final class GeoJSONDecodableTests: XCTestCase { ].joined() let data: Data = try XCTUnwrap(string.data(using: .utf8)) - let feature = try JSONDecoder().decode(Feature.self, from: data) + let feature = try JSONDecoder().decode(Feature.self, from: data) let expected: Feature = Feature( geometry: Point2D(coordinates: .nantes), @@ -179,6 +179,33 @@ final class GeoJSONDecodableTests: XCTestCase { 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.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 { diff --git a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift index 3f9b7d4..349ca49 100644 --- a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -163,4 +163,32 @@ final class GeoJSONEncodableTests: XCTestCase { 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]", + "}", + ].joined() + XCTAssertEqual(string, expected) + } + } From 351fe770a38ad06df72ac9ac2024c21b2087a814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Thu, 10 Feb 2022 21:37:14 +0100 Subject: [PATCH 07/10] =?UTF-8?q?=E2=9C=A8=20Add=20bounding=20box=20memoiz?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/GeoJSON/BoundingBox.swift | 9 +- Sources/GeoJSON/GeoJSON+Codable.swift | 2 - .../Geometries/GeometryCollection.swift | 2 +- Sources/GeoJSON/Geometries/Polygon.swift | 2 +- .../GeoJSON/Helpers/GeoJSON+Boundable.swift | 4 +- Sources/GeoJSON/Objects/Geometry.swift | 36 +++---- Sources/GeoJSON/Objects/Object.swift | 8 +- Sources/GeoJSON/Position.swift | 2 +- Sources/Turf/Boundable.swift | 98 +++++++++++++++++++ Sources/Turf/BoundingBoxCache.swift | 41 ++++++++ 10 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 Sources/Turf/Boundable.swift create mode 100644 Sources/Turf/BoundingBoxCache.swift diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift index 52f44fc..17bbbfb 100644 --- a/Sources/GeoJSON/BoundingBox.swift +++ b/Sources/GeoJSON/BoundingBox.swift @@ -9,7 +9,7 @@ import GeoModels /// A [GeoJSON Bounding Box](https://datatracker.ietf.org/doc/html/rfc7946#section-5). -public protocol BoundingBox: Hashable, Codable { +public protocol BoundingBox: GeoModels.BoundingBox, Codable { /// This bonding box, but type-erased. var asAny: AnyBoundingBox { get } @@ -37,6 +37,13 @@ extension BoundingBox3D: BoundingBox { /// A type-erased ``BoundingBox``. public enum AnyBoundingBox: BoundingBox, Hashable, Codable { + public static var zero: AnyBoundingBox = .twoDimensions(.zero) + + public func union(_ other: AnyBoundingBox) -> AnyBoundingBox { + #warning("Implement `AnyBoundingBox.union`") + fatalError("Not implemented yet") + } + case twoDimensions(BoundingBox2D) case threeDimensions(BoundingBox3D) diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index 530f7c2..4a79b1d 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -407,7 +407,6 @@ extension Feature { 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) } @@ -445,7 +444,6 @@ extension FeatureCollection { 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 - // 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 index 5b3070a..0ffc767 100644 --- a/Sources/GeoJSON/Geometries/GeometryCollection.swift +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -11,7 +11,7 @@ public struct GeometryCollection: CodableGeometry { public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection } - public var bbox: AnyBoundingBox? + public var _bbox: AnyBoundingBox { asAnyGeometry.bbox } public var asAnyGeometry: AnyGeometry { .geometryCollection(self) } diff --git a/Sources/GeoJSON/Geometries/Polygon.swift b/Sources/GeoJSON/Geometries/Polygon.swift index d74dd81..c807cd8 100644 --- a/Sources/GeoJSON/Geometries/Polygon.swift +++ b/Sources/GeoJSON/Geometries/Polygon.swift @@ -29,7 +29,7 @@ public struct LinearRingCoordinates: Boundable, Hashable, public var rawValue: RawValue - public var bbox: RawValue.BoundingBox { rawValue.bbox } + public var _bbox: RawValue.BoundingBox { rawValue.bbox } public init(rawValue: RawValue) throws { guard rawValue.first == rawValue.last else { diff --git a/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift index 0e7f8df..5f24725 100644 --- a/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift +++ b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift @@ -9,9 +9,9 @@ import NonEmpty import Turf -extension NonEmpty: Boundable where Element: Boundable { +extension NonEmpty: Boundable where Collection: Hashable, Element: Boundable { - public var bbox: Element.BoundingBox { + public var _bbox: Element.BoundingBox { self.reduce(.zero, { $0.union($1.bbox) }) } diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift index 327109c..fbcfab2 100644 --- a/Sources/GeoJSON/Objects/Geometry.swift +++ b/Sources/GeoJSON/Objects/Geometry.swift @@ -1,5 +1,5 @@ // -// File.swift +// Geometry.swift // GeoSwift // // Created by Rémi Bardon on 04/02/2022. @@ -9,9 +9,9 @@ import Turf /// A [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1). -public protocol Geometry: GeoJSON.Object, Hashable { - - var bbox: BoundingBox? { get } +public protocol Geometry: GeoJSON.Object, Boundable, Hashable +where BoundingBox: GeoJSON.BoundingBox +{ /// This geometry, but type-erased. var asAnyGeometry: AnyGeometry { get } @@ -46,7 +46,7 @@ public protocol SingleGeometry: CodableGeometry { extension SingleGeometry { - public var bbox: Coordinates.BoundingBox? { coordinates.bbox } + public var _bbox: Coordinates.BoundingBox { coordinates.bbox } } @@ -89,23 +89,23 @@ public enum AnyGeometry: Geometry, Hashable, Codable { // } // } - public var bbox: AnyBoundingBox? { + 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 .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 + 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 } } diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift index f0eec22..f2afa28 100644 --- a/Sources/GeoJSON/Objects/Object.swift +++ b/Sources/GeoJSON/Objects/Object.swift @@ -7,13 +7,7 @@ // /// A [GeoJSON Object](https://datatracker.ietf.org/doc/html/rfc7946#section-3). -public protocol Object { - - associatedtype BoundingBox: GeoJSON.BoundingBox - - var bbox: BoundingBox? { get } - -} +public protocol Object {} public protocol CodableObject: GeoJSON.Object, Codable { diff --git a/Sources/GeoJSON/Position.swift b/Sources/GeoJSON/Position.swift index b1401fb..979f442 100644 --- a/Sources/GeoJSON/Position.swift +++ b/Sources/GeoJSON/Position.swift @@ -10,7 +10,7 @@ import GeoModels import Turf /// A [GeoJSON Position](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1). -public protocol Position: Hashable, Boundable {} +public protocol Position: Boundable {} /// A ``Position`` with two elements (longitude and latitude). public typealias Position2D = Coordinate2D diff --git a/Sources/Turf/Boundable.swift b/Sources/Turf/Boundable.swift new file mode 100644 index 0000000..3bb48cc --- /dev/null +++ b/Sources/Turf/Boundable.swift @@ -0,0 +1,98 @@ +// +// Boundable.swift +// SwiftGeo +// +// Created by Rémi Bardon on 04/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +import GeoModels + +public protocol Boundable { + + associatedtype BoundingBox: GeoModels.BoundingBox + + var _bbox: BoundingBox { get } + +} + +extension Boundable { + + public var bbox: BoundingBox { _bbox } + +} + +extension Boundable where Self: Hashable { + + public var bbox: BoundingBox { BoundingBoxCache.shared.bbox(for: self) } + +} + +extension Coordinate2D { + + public var _bbox: BoundingBox2D { + BoundingBox2D(southWest: self, width: .zero, height: .zero) + } + +} + +extension Coordinate3D: Boundable { + + public var _bbox: BoundingBox3D { + BoundingBox3D(southWestLow: self, width: .zero, height: .zero, zHeight: .zero) + } + +} + +extension Line2D: Boundable { + + public var _bbox: BoundingBox2D { + Turf.bbox(for: [start, end])! + } + +} + +extension Line3D: Boundable { + + public var _bbox: BoundingBox3D { + Turf.bbox(for: [start, end])! + } + +} + +extension BoundingBox2D: Boundable { + + public var _bbox: BoundingBox2D { self } + +} + +extension BoundingBox3D: Boundable { + + public var _bbox: BoundingBox3D { self } + +} + +// Extension of protocol 'Collection' cannot have an inheritance clause +//extension Collection: Boundable where Element: Boundable { +// +// public var _bbox: Element.BoundingBox { +// self.reduce(.zero, { $0.union($1.bbox) }) +// } +// +//} + +extension Array: Boundable where Element: Boundable { + + public var _bbox: Element.BoundingBox { + self.reduce(.zero, { $0.union($1.bbox) }) + } + +} + +extension Set: Boundable where Element: Boundable { + + public var _bbox: Element.BoundingBox { + self.reduce(.zero, { $0.union($1.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(_ value: B, forKey key: K) { + values[AnyHashable(key)] = value + } + + internal func get(_ type: B.Type, forKey key: K) -> B? { + values[AnyHashable(key)] as? B + } + + public func bbox(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(for key: K) { + values.removeValue(forKey: AnyHashable(key)) + } + +} From 29b746dbadf475068148f112b8e84d6b7975b612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Thu, 10 Feb 2022 22:49:13 +0100 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=A8=20Encode=20bounding=20boxes=20i?= =?UTF-8?q?n=20GeoJSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix bounding boxes computation --- Sources/GeoJSON/GeoJSON+Codable.swift | 3 +- .../GeoJSON/Helpers/GeoJSON+Boundable.swift | 2 +- Sources/GeoModels/BoundingBox.swift | 23 +++ Sources/Turf/Boundable.swift | 6 +- Sources/Turf/Turf.swift | 150 ++++++++++++++++++ .../GeoJSONTests/GeoJSON+EncodableTests.swift | 24 ++- 6 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 Sources/GeoModels/BoundingBox.swift create mode 100644 Sources/Turf/Turf.swift diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index 4a79b1d..e388457 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -145,7 +145,7 @@ extension LinearRingCoordinates { fileprivate enum SingleGeometryCodingKeys: String, CodingKey { case geoJSONType = "type" - case coordinates + case coordinates, bbox } extension SingleGeometry { @@ -171,6 +171,7 @@ extension SingleGeometry { try container.encode(Self.geoJSONType, forKey: .geoJSONType) try container.encode(self.coordinates, forKey: .coordinates) + try container.encode(self.bbox, forKey: .bbox) } } diff --git a/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift index 5f24725..3cc1e05 100644 --- a/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift +++ b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift @@ -12,7 +12,7 @@ import Turf extension NonEmpty: Boundable where Collection: Hashable, Element: Boundable { public var _bbox: Element.BoundingBox { - self.reduce(.zero, { $0.union($1.bbox) }) + self.reduce(nil, { $0.union($1.bbox) }) ?? .zero } } 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/Boundable.swift b/Sources/Turf/Boundable.swift index 3bb48cc..1e8343d 100644 --- a/Sources/Turf/Boundable.swift +++ b/Sources/Turf/Boundable.swift @@ -76,7 +76,7 @@ extension BoundingBox3D: Boundable { //extension Collection: Boundable where Element: Boundable { // // public var _bbox: Element.BoundingBox { -// self.reduce(.zero, { $0.union($1.bbox) }) +// self.reduce(nil, { $0.union($1.bbox) }) ?? .zero // } // //} @@ -84,7 +84,7 @@ extension BoundingBox3D: Boundable { extension Array: Boundable where Element: Boundable { public var _bbox: Element.BoundingBox { - self.reduce(.zero, { $0.union($1.bbox) }) + self.reduce(nil, { $0.union($1.bbox) }) ?? .zero } } @@ -92,7 +92,7 @@ extension Array: Boundable where Element: Boundable { extension Set: Boundable where Element: Boundable { public var _bbox: Element.BoundingBox { - self.reduce(.zero, { $0.union($1.bbox) }) + self.reduce(nil, { $0.union($1.bbox) }) ?? .zero } } 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(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(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(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 +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 +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.. +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+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift index 349ca49..66522eb 100644 --- a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -26,7 +26,8 @@ final class GeoJSONEncodableTests: XCTestCase { XCTAssertEqual(string, [ "{", "\"type\":\"Point\",", - "\"coordinates\":[-1.55366,47.21881]", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", "}", ].joined()) } @@ -41,7 +42,8 @@ final class GeoJSONEncodableTests: XCTestCase { "\"type\":\"MultiPoint\",", "\"coordinates\":[", "[-1.55366,47.21881]", - "]", + "],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", "}", ].joined()) } @@ -57,7 +59,8 @@ final class GeoJSONEncodableTests: XCTestCase { "\"coordinates\":[", "[-1.55366,47.21881],", "[2.3529,48.85719]", - "]", + "],", + "\"bbox\":[-1.55366,47.21881,2.3529,48.85719]", "}", ].joined()) } @@ -79,7 +82,8 @@ final class GeoJSONEncodableTests: XCTestCase { "[2.3529,48.85719],", "[5.36468,43.29868]", "]", - "]", + "],", + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719]", "}", ].joined()) } @@ -102,7 +106,8 @@ final class GeoJSONEncodableTests: XCTestCase { "[2.3529,48.85719],", "[-1.55366,47.21881]", "]", - "]", + "],", + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719]", "}", ].joined()) } @@ -133,7 +138,8 @@ final class GeoJSONEncodableTests: XCTestCase { "[-1.55366,47.21881]", "]", "]", - "]", + "],", + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719]", "}", ].joined()) } @@ -155,7 +161,8 @@ final class GeoJSONEncodableTests: XCTestCase { "\"type\":\"Feature\",", "\"geometry\":{", "\"type\":\"Point\",", - "\"coordinates\":[-1.55366,47.21881]", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", "},", "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", "}", @@ -183,7 +190,8 @@ final class GeoJSONEncodableTests: XCTestCase { "\"type\":\"Feature\",", "\"geometry\":{", "\"type\":\"Point\",", - "\"coordinates\":[-1.55366,47.21881]", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", "},", "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", "}", From 739ec70cb86c5776a8405a1bbdbfd02279af6201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Thu, 10 Feb 2022 23:52:25 +0100 Subject: [PATCH 09/10] =?UTF-8?q?=E2=9C=A8=20Implement=20`AnyBoundingBox.u?= =?UTF-8?q?nion`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix `GeometryCollection` encoding --- Sources/GeoJSON/BoundingBox.swift | 13 +- Sources/GeoJSON/GeoJSON+Codable.swift | 33 ++++ .../Geometries/GeometryCollection.swift | 6 +- .../GeoJSON/Objects/FeatureCollection.swift | 5 +- Sources/GeoModels/2D/BoundingBox2D.swift | 153 ++++++++++++++++++ Sources/GeoModels/3D/BoundingBox3D.swift | 121 ++++++++++++++ .../GeoJSONTests/GeoJSON+EncodableTests.swift | 80 +++++++++ 7 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 Sources/GeoModels/2D/BoundingBox2D.swift create mode 100644 Sources/GeoModels/3D/BoundingBox3D.swift diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift index 17bbbfb..027ec10 100644 --- a/Sources/GeoJSON/BoundingBox.swift +++ b/Sources/GeoJSON/BoundingBox.swift @@ -40,8 +40,17 @@ public enum AnyBoundingBox: BoundingBox, Hashable, Codable { public static var zero: AnyBoundingBox = .twoDimensions(.zero) public func union(_ other: AnyBoundingBox) -> AnyBoundingBox { - #warning("Implement `AnyBoundingBox.union`") - fatalError("Not implemented yet") + 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) diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index e388457..70059ec 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -176,6 +176,39 @@ extension SingleGeometry { } +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" } diff --git a/Sources/GeoJSON/Geometries/GeometryCollection.swift b/Sources/GeoJSON/Geometries/GeometryCollection.swift index 0ffc767..cc1fdc7 100644 --- a/Sources/GeoJSON/Geometries/GeometryCollection.swift +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -11,10 +11,14 @@ public struct GeometryCollection: CodableGeometry { public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection } - public var _bbox: AnyBoundingBox { asAnyGeometry.bbox } + 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/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift index 46bd03c..bdc26ff 100644 --- a/Sources/GeoJSON/Objects/FeatureCollection.swift +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -17,8 +17,9 @@ public struct FeatureCollection< public var features: [Feature] - // FIXME: Fix bounding box - public var bbox: AnyBoundingBox? { nil } + public var bbox: AnyBoundingBox? { + features.compactMap(\.bbox).reduce(nil, { $0.union($1.asAny) }) + } } 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/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift index 66522eb..725f8fb 100644 --- a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -199,4 +199,84 @@ final class GeoJSONEncodableTests: XCTestCase { 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) + } + } From 5a5821fc4e62b434c0d0f255320f2ed6474bd690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Thu, 2 Feb 2023 10:33:45 +0100 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=9A=A7=20WIP=20Fix=20some=20rebase?= =?UTF-8?q?=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcschemes/swift-geo-Package.xcscheme | 24 +++++ Package.swift | 5 +- Sources/Turf/Boundable.swift | 98 ------------------- 3 files changed, 27 insertions(+), 100 deletions(-) delete mode 100644 Sources/Turf/Boundable.swift 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:"> + + + + + + + +