Skip to content

Commit

Permalink
Always include the m value of Coordinate3D in JSON (#57)
Browse files Browse the repository at this point in the history
  • Loading branch information
trasch authored Jul 2, 2024
1 parent 1a57d88 commit 831a87e
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 26 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
42 changes: 22 additions & 20 deletions Sources/GISTools/GeoJson/Coordinate3D.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -432,7 +432,7 @@ extension Sequence<Coordinate3D> {
/// 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)
}

Expand All @@ -442,6 +442,8 @@ extension Sequence<Coordinate3D> {

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)
Expand Down
7 changes: 5 additions & 2 deletions Sources/GISTools/GeoJson/GeoJsonCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
Expand Down
51 changes: 49 additions & 2 deletions Tests/GISToolsTests/GeoJson/CoordinateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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])
}

}

0 comments on commit 831a87e

Please sign in to comment.