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

#42: Encode/decode polylines #43

Merged
merged 2 commits into from
Mar 11, 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
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)
}

}
Loading