Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Always include the m value of Coordinate3D in JSON #57

Merged
merged 1 commit into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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])
}

}
Loading