diff --git a/README.md b/README.md index 240b482..014da7a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This package requires Swift 5.10 or higher (at least Xcode 14), and compiles on ```swift dependencies: [ - .package(url: "https://github.com/Outdooractive/gis-tools", from: "1.6.0"), + .package(url: "https://github.com/Outdooractive/gis-tools", from: "1.7.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ @@ -36,6 +36,8 @@ targets: [ - Includes many spatial algorithms (ported from turf.js), and more to come - Has a helper for working with x/y/z map tiles (center/bounding box/resolution/…) - Can encode/decode Polylines +- Pure Swift without external dependencies +- Swift 6 ready ## Usage @@ -297,7 +299,10 @@ var altitude: CLLocationDistance? /// The GeoJSON specification doesn't specifiy the meaning of this value, /// and it doesn't guarantee that parsers won't ignore or discard it. See /// https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1. -/// - Important: `asJson` will output `m` only if the coordinate also has an `altitude`. +/// - Important: The JSON for a coordinate will contain a `null` altitude value +/// if `altitude` is `nil` so that `m` won't get lost (since it is +/// the 4th value). +/// This might lead to compatibilty issues with other GeoJSON readers. var m: Double? /// Alias for longitude diff --git a/Sources/GISTools/GeoJson/Coordinate3D.swift b/Sources/GISTools/GeoJson/Coordinate3D.swift index 9cb8bf1..c3f80ca 100644 --- a/Sources/GISTools/GeoJson/Coordinate3D.swift +++ b/Sources/GISTools/GeoJson/Coordinate3D.swift @@ -36,7 +36,8 @@ public struct Coordinate3D: /// The GeoJSON specification doesn't specifiy the meaning of this value, /// and it doesn't guarantee that parsers won't ignore or discard it. See /// [chapter 3.1.1 in the spec](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1). - /// - Important: ``asJson`` will output `m` only if the coordinate also has an ``altitude``. + /// - Important: ``asJson`` will output a `null` altitude value if ``altitude`` is `nil` so that + /// `m` won't get lost. This might lead to compatibilty issues with other GeoJSON readers. public var m: Double? /// Alias for longitude @@ -350,42 +351,41 @@ extension Coordinate3D: GeoJsonReadable { /// - Note: The [GeoJSON spec](https://datatracker.ietf.org/doc/html/rfc7946) /// uses CRS:84 that specifies coordinates in longitude/latitude order. /// - Important: The third value will always be ``altitude``, the fourth value - /// will be ``m`` if it exists. + /// will be ``m`` if it exists. ``altitude`` can be a JSON `null` value. /// - important: The source is expected to be in EPSG:4326. public init?(json: Any?) { - guard let pointArray = json as? [Double], - pointArray.count >= 2 + guard let pointArray = json as? [Double?], + pointArray.count >= 2, + let pLongitude = pointArray[0], + let pLatitude = pointArray[1] else { return nil } - if pointArray.count == 2 { - self.init(latitude: pointArray[1], longitude: pointArray[0]) - } - else if pointArray.count == 3 { - self.init(latitude: pointArray[1], longitude: pointArray[0], altitude: pointArray[2]) - } - else { - self.init(latitude: pointArray[1], longitude: pointArray[0], altitude: pointArray[2], m: pointArray[3]) - } + let pAltitude: CLLocationDistance? = if pointArray.count >= 3 { pointArray[2] } else { nil } + let pM: Double? = if pointArray.count >= 4 { pointArray[3] } else { nil } + + self.init(latitude: pLatitude, longitude: pLongitude, altitude: pAltitude, m: pM) } /// Dump the coordinate as a JSON object. /// - /// - Important: The output array will contain ``m`` only if this coordinate - /// also contains ``altitude`` to prevent any disambiguity. + /// - Important: The result JSON object will have a `null` value for the altitude + /// if the ``altitude`` is `nil` and ``m`` exists. /// - important: Always projected to EPSG:4326, unless the coordinate has no SRID. - public var asJson: [Double] { - var result: [Double] = (projection == .epsg4326 || projection == .noSRID + public var asJson: [Double?] { + var result: [Double?] = (projection == .epsg4326 || projection == .noSRID ? [longitude, latitude] : [longitudeProjected(to: .epsg4326), latitudeProjected(to: .epsg4326)]) if let altitude { result.append(altitude) - - // We can't add `m` if we don't have an altitude if let m { result.append(m) } } + else if let m { + result.append(nil) + result.append(m) + } return result } @@ -432,7 +432,7 @@ extension Sequence { /// Returns all elements as an array of JSON objects /// /// - important: Always projected to EPSG:4326, unless the coordinate has no SRID. - public var asJson: [[Double]] { + public var asJson: [[Double?]] { self.map(\.asJson) } @@ -442,6 +442,8 @@ extension Sequence { extension Coordinate3D: Equatable { + /// Coordinates are regarded as equal when they are within a few μm from each other. + /// See ``GISTool.equalityDelta``. public static func == ( lhs: Coordinate3D, rhs: Coordinate3D) diff --git a/Sources/GISTools/GeoJson/GeoJsonCodable.swift b/Sources/GISTools/GeoJson/GeoJsonCodable.swift index e22f1e8..da13730 100644 --- a/Sources/GISTools/GeoJson/GeoJsonCodable.swift +++ b/Sources/GISTools/GeoJson/GeoJsonCodable.swift @@ -234,8 +234,8 @@ extension KeyedDecodingContainer where Key == GeoJsonCodingKey { extension UnkeyedDecodingContainer { - fileprivate mutating func decodeGeoJsonArray() -> [Any] { - var result: [Any] = [] + fileprivate mutating func decodeGeoJsonArray() -> [Any?] { + var result: [Any?] = [] while !isAtEnd { // Again, order is important @@ -254,6 +254,9 @@ extension UnkeyedDecodingContainer { else if let decoded = try? decode(Float.self) { result.append(decoded) } + else if let isNil = try? decodeNil(), isNil { + result.append(nil) + } else if var decoded = try? nestedUnkeyedContainer() { result.append(decoded.decodeGeoJsonArray()) } diff --git a/Tests/GISToolsTests/GeoJson/CoordinateTests.swift b/Tests/GISToolsTests/GeoJson/CoordinateTests.swift index 432da60..d555af6 100644 --- a/Tests/GISToolsTests/GeoJson/CoordinateTests.swift +++ b/Tests/GISToolsTests/GeoJson/CoordinateTests.swift @@ -38,13 +38,24 @@ final class CoordinateTests: XCTestCase { XCTAssertEqual(String(data: coordinateData, encoding: .utf8), "[10,15]") } + func testEncodableNull() throws { + let coordinateM = Coordinate3D(latitude: 15.0, longitude: 10.0, altitude: nil, m: 1234) + let coordinateZ = Coordinate3D(latitude: 15.0, longitude: 10.0, altitude: 500.0, m: nil) + + let coordinateDataM = try JSONEncoder().encode(coordinateM) + let coordinateDataZ = try JSONEncoder().encode(coordinateZ) + + XCTAssertEqual(String(data: coordinateDataM, encoding: .utf8), "[10,15,null,1234]") + XCTAssertEqual(String(data: coordinateDataZ, encoding: .utf8), "[10,15,500]") + } + func testEncodable3857() throws { let coordinate = Coordinate3D(latitude: 15.0, longitude: 10.0).projected(to: .epsg3857) let coordinateData = try JSONEncoder().encode(coordinate) let decodedCoordinate = try JSONDecoder().decode(Coordinate3D.self, from: coordinateData) - XCTAssertEqual(Double(decodedCoordinate.asJson[0]), 10.0, accuracy: 0.000001) - XCTAssertEqual(Double(decodedCoordinate.asJson[1]), 15.0, accuracy: 0.000001) + XCTAssertEqual(Double(decodedCoordinate.asJson[0]!), 10.0, accuracy: 0.000001) + XCTAssertEqual(Double(decodedCoordinate.asJson[1]!), 15.0, accuracy: 0.000001) } func testDecodable() throws { @@ -54,4 +65,40 @@ final class CoordinateTests: XCTestCase { XCTAssertEqual(decodedCoordinate.asJson, [10.0, 15.0]) } + func testDecodableInvalid() throws { + let coordinateData1 = try XCTUnwrap("[10]".data(using: .utf8)) + XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData1)) + + let coordinateData2 = try XCTUnwrap("[10,]".data(using: .utf8)) + XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData2)) + + let coordinateData3 = try XCTUnwrap("[null,null]".data(using: .utf8)) + XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData3)) + + let coordinateData4 = try XCTUnwrap("[]".data(using: .utf8)) + XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData4)) + + let coordinateData5 = try XCTUnwrap("[,15]".data(using: .utf8)) + XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData5)) + } + + func testDecodableInvalidNull() throws { + let coordinateDataM = try XCTUnwrap("[10,null,null,1234]".data(using: .utf8)) + XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateDataM)) + } + + func testDecodableNull() throws { + let coordinateDataM = try XCTUnwrap("[10,15,null,1234]".data(using: .utf8)) + let coordinateDataZ = try XCTUnwrap("[10,15,500]".data(using: .utf8)) + let coordinateDataZM = try XCTUnwrap("[10,15,500,null]".data(using: .utf8)) + + let decodedCoordinateM = try JSONDecoder().decode(Coordinate3D.self, from: coordinateDataM) + let decodedCoordinateZ = try JSONDecoder().decode(Coordinate3D.self, from: coordinateDataZ) + let decodedCoordinateZM = try JSONDecoder().decode(Coordinate3D.self, from: coordinateDataZM) + + XCTAssertEqual(decodedCoordinateM.asJson, [10.0, 15.0, nil, 1234]) + XCTAssertEqual(decodedCoordinateZ.asJson, [10.0, 15.0, 500]) + XCTAssertEqual(decodedCoordinateZM.asJson, [10.0, 15.0, 500]) + } + }