diff --git a/README.md b/README.md index c9e0200..b913761 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This package requires Swift 5.9 or higher (at least Xcode 13), and compiles on i ```swift dependencies: [ - .package(url: "https://github.com/Outdooractive/gis-tools", from: "1.0.0"), + .package(url: "https://github.com/Outdooractive/gis-tools", from: "1.3.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ @@ -35,6 +35,7 @@ targets: [ - Spatial search with a R-tree - Includes many spatial algorithms, and more to come - Has a helper for working with x/y/z map tiles (center/bounding box/resolution/…) +- Can encode/decode Polylines ## Usage @@ -778,6 +779,14 @@ Also, not directly related to map tiles: let mpp = MapTile.metersPerPixel(at: 15.0, latitude: 45.0) ``` +# Polylines +Provides an encoder/decoder for Polylines. + +```swift +let polyline = [Coordinate3D(latitude: 47.56, longitude: 10.22)].encodePolyline() +let coordinates = polyline.decodePolyline() +``` + # Algorithms Hint: Most algorithms are optimized for EPSG:4326. Using other projections will have a performance penalty due to added projections. diff --git a/Sources/GISTools/Extensions/DataExtensions.swift b/Sources/GISTools/Extensions/DataExtensions.swift index 3825e66..3ba3456 100644 --- a/Sources/GISTools/Extensions/DataExtensions.swift +++ b/Sources/GISTools/Extensions/DataExtensions.swift @@ -18,6 +18,10 @@ extension Data { self.init(bytes) } + var asUTF8EncodedString: String? { + String(data: self, encoding: .utf8) + } + /// The data, or nil if it is empty var nilIfEmpty: Data? { guard !isEmpty else { return nil } diff --git a/Sources/GISTools/Extensions/DoubleExtensions.swift b/Sources/GISTools/Extensions/DoubleExtensions.swift index f0187e2..ea1d3bd 100644 --- a/Sources/GISTools/Extensions/DoubleExtensions.swift +++ b/Sources/GISTools/Extensions/DoubleExtensions.swift @@ -74,4 +74,8 @@ extension Double { self = rounded(precision: precision) } + var toInt: Int { + Int(self) + } + } diff --git a/Sources/GISTools/Extensions/StringExtensions.swift b/Sources/GISTools/Extensions/StringExtensions.swift index 27a193e..081fab1 100644 --- a/Sources/GISTools/Extensions/StringExtensions.swift +++ b/Sources/GISTools/Extensions/StringExtensions.swift @@ -34,4 +34,8 @@ extension String { return self } + var asUTF8EncodedData: Data? { + self.data(using: .utf8) + } + } diff --git a/Sources/GISTools/GISTool.swift b/Sources/GISTools/GISTool.swift index a884bd8..08b46b3 100644 --- a/Sources/GISTools/GISTool.swift +++ b/Sources/GISTools/GISTool.swift @@ -23,4 +23,7 @@ public enum GISTool { /// The length in pixels of a map tile. public static let tileSideLength: Double = 256.0 + /// The default precision for encoding/decoding Polylines. + public static let defaultPolylinePrecision: Double = 1e5 + } diff --git a/Sources/GISTools/GeoJson/Polyline.swift b/Sources/GISTools/GeoJson/Polyline.swift new file mode 100644 index 0000000..e1fcb10 --- /dev/null +++ b/Sources/GISTools/GeoJson/Polyline.swift @@ -0,0 +1,172 @@ +#if !os(Linux) + import CoreLocation +#endif +import Foundation + +extension [Coordinate3D] { + + /// Encodes the coordinates to a Polyline with the given precision. + public func encodePolyline(precision: Double = GISTool.defaultPolylinePrecision) -> String { + Polyline.encode(coordinates: self, precision: precision) + } + +} + +extension String { + + /// Decodes a Polyline to coordinates with the given precision (must match the encoding precision). + public func decodePolyline(precision: Double = GISTool.defaultPolylinePrecision) -> [Coordinate3D]? { + Polyline.decode(polyline: self, precision: precision) + } + +} + +// Algorithm: https://developers.google.com/maps/documentation/utilities/polylinealgorithm +enum Polyline { + + /// Encodes the coordinates to a Polyline with the given precision. + public static func encode( + coordinates: [Coordinate3D], + precision: Double = GISTool.defaultPolylinePrecision) + -> String + { + var previousIntLatitude = 0, + previousIntLongitude = 0 + + var result = "" + + for coordinate in coordinates { + let intLatitude = (coordinate.latitude * precision).rounded().toInt + let intLongitude = (coordinate.longitude * precision).rounded().toInt + + result += encodeInt(intLatitude - previousIntLatitude) + result += encodeInt(intLongitude - previousIntLongitude) + + previousIntLatitude = intLatitude + previousIntLongitude = intLongitude + } + + return result + } + + /// Decodes a Polyline to coordinates with the given precision (must match the encoding precision). + public static func decode( + polyline: String, + precision: Double = GISTool.defaultPolylinePrecision) + -> [Coordinate3D]? + { + guard let data = polyline.asUTF8EncodedData else { return nil } + + let length = data.count + return data.withUnsafeBytes({ buffer -> [Coordinate3D]? in + var coordinates: [Coordinate3D] = [] + + var position = 0 + var latitude = 0.0 + var longitude = 0.0 + + while position < length { + guard + let currentLatitude = decodeValue( + buffer: buffer, + position: &position, + length: length, + precision: precision), + let currentLongitude = decodeValue( + buffer: buffer, + position: &position, + length: length, + precision: precision) + else { return nil } + + latitude += currentLatitude + longitude += currentLongitude + + coordinates.append(Coordinate3D(latitude: latitude, longitude: longitude)) + } + + return coordinates + }) + } + + // MARK: - Private + + private static let firstBitBitmask = 0b0000_0001 + private static let fiveBitsBitmask = 0b0001_1111 + private static let sixthBitBitmask = 0b0010_0000 + private static let base64BaseValue = 63 + + private static func encodeInt(_ value: Int) -> String { + var value = value + if value < 0 { + value = value << 1 + value = ~value + } + else { + value = value << 1 + } + + var result = "" + var fiveBitChunk = 0 + + repeat { + fiveBitChunk = value & fiveBitsBitmask + + if value >= sixthBitBitmask { + fiveBitChunk |= sixthBitBitmask + } + + value = value >> 5 + fiveBitChunk += base64BaseValue + + result += String(UnicodeScalar(fiveBitChunk)!) + } + while value != 0 + + return result + } + + private static func decodeValue( + buffer: UnsafeRawBufferPointer, + position: inout Int, + length: Int, + precision: Double) + -> Double? + { + guard position < length else { return nil } + + var value = 0 + var scalar = 0 + var components = 0 + var fiveBitChunk = 0 + + repeat { + scalar = Int(buffer[position]) - base64BaseValue + fiveBitChunk = scalar & fiveBitsBitmask + + value |= (fiveBitChunk << (5 * components)) + + position += 1 + components += 1 + } + while (scalar & sixthBitBitmask) == sixthBitBitmask + && position < length + && components < 6 + + if components == 6, + (scalar & sixthBitBitmask) == sixthBitBitmask + { + return nil + } + + if (value & firstBitBitmask) == firstBitBitmask { + value = ~(value >> 1) + } + else { + value = value >> 1 + } + + return Double(value) / precision + } + +} diff --git a/Tests/GISToolsTests/GeoJson/PolylineTests.swift b/Tests/GISToolsTests/GeoJson/PolylineTests.swift new file mode 100644 index 0000000..0247e62 --- /dev/null +++ b/Tests/GISToolsTests/GeoJson/PolylineTests.swift @@ -0,0 +1,30 @@ +@testable import GISTools +import XCTest + +final class PolylineTests: XCTestCase { + + let coordinates: [Coordinate3D] = [ + .init(latitude: 38.5, longitude: -120.2), + .init(latitude: 40.7, longitude: -120.95), + .init(latitude: 43.252, longitude: -126.453), + ] + let polylines: [String] = [ + "_p~iF~ps|U", + "_flwFn`faV", + "_t~fGfzxbW", + ] + let encodedPolyline = "_p~iF~ps|U_ulLnnqC_mqNvxq`@" + + func testEncodePolyline() throws { + for (i, coordinate) in coordinates.enumerated() { + XCTAssertEqual(Polyline.encode(coordinates: [coordinate]), polylines[i]) + } + + XCTAssertEqual(coordinates.encodePolyline(), encodedPolyline) + } + + func testDecodePolyline() throws { + XCTAssertEqual(encodedPolyline.decodePolyline(), coordinates) + } + +}