Skip to content
Open
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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@ dependencies {

dokka(project(":gpx"))
kover(project(":gpx"))

dokka(project(":polyline-encoding"))
kover(project(":polyline-encoding"))
}
3 changes: 3 additions & 0 deletions polyline-encoding/MODULE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Module polyline-encoding

Encoding and decoding of the Encoded Polyline Algorithm Format.
6 changes: 6 additions & 0 deletions polyline-encoding/api/polyline-encoding.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
public final class org/maplibre/spatialk/polyline/PolylineEncoding {
public static final field INSTANCE Lorg/maplibre/spatialk/polyline/PolylineEncoding;
public final fun decode (Ljava/lang/String;I)Ljava/util/List;
public final fun encode (Ljava/util/List;I)Ljava/lang/String;
}

12 changes: 12 additions & 0 deletions polyline-encoding/api/polyline-encoding.klib.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Klib ABI Dump
// Targets: [androidNativeArm32, androidNativeArm64, androidNativeX64, androidNativeX86, iosArm64, iosSimulatorArm64, iosX64, js, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, wasmJs, wasmWasi, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true

// Library unique name: <org.maplibre.spatialk:polyline-encoding>
final object org.maplibre.spatialk.polyline/PolylineEncoding { // org.maplibre.spatialk.polyline/PolylineEncoding|null[0]
final fun decode(kotlin/String, kotlin/Int): kotlin.collections/List<org.maplibre.spatialk.geojson/Position> // org.maplibre.spatialk.polyline/PolylineEncoding.decode|decode(kotlin.String;kotlin.Int){}[0]
final fun encode(kotlin.collections/List<org.maplibre.spatialk.geojson/Position>, kotlin/Int): kotlin/String // org.maplibre.spatialk.polyline/PolylineEncoding.encode|encode(kotlin.collections.List<org.maplibre.spatialk.geojson.Position>;kotlin.Int){}[0]
}
19 changes: 19 additions & 0 deletions polyline-encoding/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
plugins {
id("published-library")
id("test-resources")
}

kotlin {
sourceSets {
commonMain.dependencies {
api(project(":geojson"))
}
}
}

mavenPublishing {
pom {
name = "Spatial K Polyline Encoding"
description = "A Kotlin Multiplatform library for encoding & decoding polylines."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.maplibre.spatialk.polyline

import kotlin.math.pow
import kotlin.math.roundToLong
import org.maplibre.spatialk.geojson.Position

/**
* Encodes and decodes coordinate sequences using the Encoded Polyline Algorithm.
*
* The encoding logic is run following steps. Decoding the same in reversed order.
* 1. Take the initial signed value
* 2. Take the decimal value and multiply it by 10^precision, rounding the result
* 3. Convert the decimal value to binary. Note that a negative value must be calculated using its two's complement by inverting the binary value and adding one to the result:
* 4. Left-shift the binary value one bit:
* 5. If the original decimal value is negative, invert this encoding
* 6. Break the binary value out into 5-bit chunks (starting from the right hand side)
* 7. Place the 5-bit chunks into reverse order
* 8. OR each value with 0x20 if another bit chunk follows
* 9. Convert each value to decimal
* 10. Add 63 to each value
* 11. Convert each value to its ASCII equivalent
*
* @see <a href="https://developers.google.com/maps/documentation/utilities/polylinealgorithm">
* Google Encoded Polyline Algorithm Format</a>
*/
public object PolylineEncoding {

/**
* Encode a list of coordinates to an encoded polyline string.
*
* @param coordinates the list of [Position] objects to encode
* @param precision the number of decimal digits to encode (e.g. 5 for 1e5, the standard Google
* precision)
* @return the encoded polyline string
*/
public fun encode(coordinates: List<Position>, precision: Int): String {
val factor = 10.0.pow(precision)
val result = StringBuilder()
var prevLat = 0L
var prevLon = 0L

for (position in coordinates) {
val lat = (position.latitude * factor).roundToLong()
val lon = (position.longitude * factor).roundToLong()

encodeValue(lat - prevLat, result)
encodeValue(lon - prevLon, result)

prevLat = lat
prevLon = lon
}

return result.toString()
}

/**
* Decode an encoded polyline string to a list of coordinates.
*
* @param encoded the encoded polyline string
* @param precision the number of decimal digits used during encoding (e.g. 5 for the standard
* Google precision)
* @return the decoded list of [Position] objects
*/
public fun decode(encoded: String, precision: Int): List<Position> {
val factor = 10.0.pow(precision)
val result = mutableListOf<Position>()
var index = 0
var lat = 0L
var lon = 0L

while (index < encoded.length) {
lat += decodeValue(encoded, index).also { index += it.chunkCount }.value
lon += decodeValue(encoded, index).also { index += it.chunkCount }.value

result.add(Position(longitude = lon / factor, latitude = lat / factor))
}

return result
}

private fun encodeValue(value: Long, result: StringBuilder) {
var encoded = if (value < 0) (value shl 1).inv() else value shl 1

while (encoded >= 0x20L) {
result.append(((0x20L or (encoded and 0x1FL)) + 63L).toInt().toChar())
encoded = encoded ushr 5
}
result.append((encoded + 63L).toInt().toChar())
}

private data class DecodedValue(val value: Long, val chunkCount: Int)

private fun decodeValue(encoded: String, startIndex: Int): DecodedValue {
var result = 0L
var shift = 0
var index = startIndex

var b: Int
do {
b = encoded[index++].code - 63
result = result or ((b and 0x1F).toLong() shl shift)
shift += 5
} while (b >= 0x20)

val value = if (result and 1L != 0L) (result shr 1).inv() else result shr 1
return DecodedValue(value, index - startIndex)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.maplibre.spatialk.polyline

import kotlin.math.pow
import kotlin.math.roundToLong
import org.maplibre.spatialk.geojson.Position
import kotlin.test.Test
import kotlin.test.assertEquals

class PolylineEncodingTest {

private val positions = listOf(
Position(latitude = 0.0, longitude = 0.0),
Position(latitude = 1.2345678, longitude = 2.3456789),
Position(latitude = -3.3, longitude = -3.3),
Position(latitude = 0.0, longitude = 0.0),
)
private val encodedPolyline5 = "??acpFociM`ttZntma@_pcS_pcS"
private val encodedPolyline6 = "??ogjjA}kdnCnqwsG|uqwI_ilhE_ilhE"

@Test
fun `Test encode with precision 5`() {
val encoded = PolylineEncoding.encode(positions, 5)
assertEquals(encodedPolyline5, encoded)
}

@Test
fun `Test decode with precision 5`() {
val decodedPositions = PolylineEncoding.decode(encodedPolyline5, 5)
assertEquals(positions.round(5), decodedPositions)
}

@Test
fun `Test encode with precision 6`() {
val encoded = PolylineEncoding.encode(positions, 6)
assertEquals(encodedPolyline6, encoded)
}

@Test
fun `Test decode with precision 6`() {
val decodedPositions = PolylineEncoding.decode(encodedPolyline6, 6)
assertEquals(positions.round(6), decodedPositions)
}

private fun List<Position>.round(precision: Int) = map {
val factor = 10.0.pow(precision)
Position(
latitude = (it.latitude * factor).roundToLong() / factor,
longitude = (it.longitude * factor).roundToLong() / factor,
)
}
}
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ dependencyResolutionManagement {

rootProject.name = "spatial-k"

include(":geojson", ":gpx", ":units", ":turf", ":testutil", ":benchmark")
include(":geojson", ":gpx", ":units", ":turf", ":testutil", ":benchmark", ":polyline-encoding")
Loading