Skip to content

Commit

Permalink
#42: Encode/decode polylines (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
trasch authored Mar 11, 2024
1 parent b1d2ab6 commit 30e1e1f
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 1 deletion.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions Sources/GISTools/Extensions/DataExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions Sources/GISTools/Extensions/DoubleExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,8 @@ extension Double {
self = rounded(precision: precision)
}

var toInt: Int {
Int(self)
}

}
4 changes: 4 additions & 0 deletions Sources/GISTools/Extensions/StringExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ extension String {
return self
}

var asUTF8EncodedData: Data? {
self.data(using: .utf8)
}

}
3 changes: 3 additions & 0 deletions Sources/GISTools/GISTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
172 changes: 172 additions & 0 deletions Sources/GISTools/GeoJson/Polyline.swift
Original file line number Diff line number Diff line change
@@ -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
}

}
30 changes: 30 additions & 0 deletions Tests/GISToolsTests/GeoJson/PolylineTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}

}

0 comments on commit 30e1e1f

Please sign in to comment.