From 1e93e77526236d267460fc9934295a12e1b5c345 Mon Sep 17 00:00:00 2001
From: Mike Balfour <82224783+mbalfour-amzn@users.noreply.github.com>
Date: Fri, 6 Sep 2024 16:09:24 -0400
Subject: [PATCH] Converted Javascript library and unit tests to Swift. (#7)
* Converted Javascript library and unit tests to Swift.
* Add autobuild for swift.
---
.github/workflows/build-swift.yml | 24 +
.gitignore | 20 +-
Package.swift | 25 +
.../xcshareddata/IDEWorkspaceChecks.plist | 8 +
.../Sources/Polyline/Algorithm/Decoder.swift | 181 +++++
.../Sources/Polyline/Algorithm/Encoder.swift | 159 ++++
.../Compressors/EncodedPolyline.swift | 83 ++
.../Compressors/FlexiblePolyline.swift | 61 ++
.../Sources/Polyline/DataCompressor.swift | 260 ++++++
.../Polyline/Sources/Polyline/Polyline.swift | 253 ++++++
.../Sources/Polyline/PolylineTypes.swift | 95 +++
.../Tests/PolylineTests/PolylineTests.swift | 764 ++++++++++++++++++
12 files changed, 1932 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/build-swift.yml
create mode 100644 Package.swift
create mode 100644 swift/Polyline/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
create mode 100644 swift/Polyline/Sources/Polyline/Algorithm/Decoder.swift
create mode 100644 swift/Polyline/Sources/Polyline/Algorithm/Encoder.swift
create mode 100644 swift/Polyline/Sources/Polyline/Compressors/EncodedPolyline.swift
create mode 100644 swift/Polyline/Sources/Polyline/Compressors/FlexiblePolyline.swift
create mode 100644 swift/Polyline/Sources/Polyline/DataCompressor.swift
create mode 100644 swift/Polyline/Sources/Polyline/Polyline.swift
create mode 100644 swift/Polyline/Sources/Polyline/PolylineTypes.swift
create mode 100644 swift/Polyline/Tests/PolylineTests/PolylineTests.swift
diff --git a/.github/workflows/build-swift.yml b/.github/workflows/build-swift.yml
new file mode 100644
index 0000000..809455d
--- /dev/null
+++ b/.github/workflows/build-swift.yml
@@ -0,0 +1,24 @@
+name: build-swift
+
+on:
+ workflow_dispatch:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+concurrency:
+ # cancel jobs on PRs only
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ build:
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build
+ run: swift build -v
+ - name: Run tests
+ run: swift test -v
diff --git a/.gitignore b/.gitignore
index b4d91a2..03ee614 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,29 @@
# Ignore common artifacts
**/build
+**/.build
**/.idea
+**/dist
+**/.DS_Store
# Ignore Javascript/npm artifacts
**/node_modules
-**/dist
**/docs
**/coverage
+# Ignore Swift artifacts
+**/Packages
+**/xcuserdata/
+**/DerivedData/
+**/.swiftpm/configuration/registries.json
+**/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+**/.netrc
+
+# Ignore Kotlin artifacts
+**/*.iml
+**/.gradle
+**/.env
+**/local.properties
+**/captures
+**/.externalNativeBuild
+**/.cxx
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..07ec000
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,25 @@
+// swift-tools-version: 5.9
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "Polyline",
+ products: [
+ // Products define the executables and libraries a package produces, making them visible to other packages.
+ .library(
+ name: "Polyline",
+ targets: ["Polyline"]),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package, defining a module or a test suite.
+ // Targets can depend on other targets in this package and products from dependencies.
+ .target(
+ name: "Polyline",
+ path: "swift/Polyline/Sources/Polyline"),
+ .testTarget(
+ name: "PolylineTests",
+ dependencies: ["Polyline"],
+ path: "swift/Polyline/Tests/PolylineTests"),
+ ]
+)
diff --git a/swift/Polyline/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/swift/Polyline/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/swift/Polyline/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/swift/Polyline/Sources/Polyline/Algorithm/Decoder.swift b/swift/Polyline/Sources/Polyline/Algorithm/Decoder.swift
new file mode 100644
index 0000000..9d572c1
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/Algorithm/Decoder.swift
@@ -0,0 +1,181 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import Foundation
+
+class PolylineDecoder {
+ // decodingTable is a lookup table that converts ASCII values from 0x00-0x7F
+ // to the appropriate decoded 0x00-0x3F value. Polyline and Flexible-Polyline
+ // use different character encodings, so they need different decoding tables.
+ let decodingTable : [Int];
+
+ // containsHeader is true if the format includes a header (Flexible-Polyline),
+ // and false if it doesn't (Polyline).
+ let containsHeader : Bool;
+
+ init(decodingTable: [Int], containsHeader: Bool) {
+ self.decodingTable = decodingTable;
+ self.containsHeader = containsHeader;
+ }
+
+ // Given an encoded string and a starting index, this decodes a single encoded signed value.
+ // The decoded value will be an integer that still needs the decimal place moved over based
+ // on the number of digits of encoded precision.
+ private func decodeSignedValue(
+ encoded: String,
+ startIndex: Int
+ ) throws -> (result: Int64, nextIndex: Int) {
+ // decode an unsigned value
+ let (unsignedValue, nextIndex) = try self.decodeUnsignedValue(
+ encoded: encoded,
+ startIndex: startIndex
+ );
+ // If the unsigned value has a 1 encoded in its least significant bit,
+ // it's negative, so flip the bits.
+ var signedValue = unsignedValue;
+ if ((unsignedValue & 1) == 1) {
+ signedValue = ~signedValue;
+ }
+ // Shift the result by one to remove the encoded sign bit.
+ signedValue >>= 1;
+ return (signedValue, nextIndex);
+ }
+
+
+ // Given an encoded string and a starting index, this decodes a single encoded
+ // unsigned value. The flexible-polyline algorithm uses this directly to decode
+ // the header bytes, since those are encoded without the sign bit as the header
+ // values are known to be unsigned (which saves 2 bits).
+ private func decodeUnsignedValue(
+ encoded: String,
+ startIndex: Int
+ ) throws -> (result: Int64, nextIndex: Int) {
+ var result:Int64 = 0;
+ var shift = 0;
+ var index = startIndex;
+
+ // For each ASCII character, get the 6-bit (0x00 - 0x3F) value that
+ // it represents. Shift the accumulated result by 5 bits, add the new
+ // 5-bit chunk to the bottom, and keep going for as long as the 6th bit
+ // is set.
+ while (index < encoded.count) {
+ let charCode = Int(encoded.unicodeScalars[encoded.index(encoded.startIndex, offsetBy: index)].value);
+ let value = self.decodingTable[charCode];
+ if (value < 0) {
+ throw DecodeError.invalidEncodedCharacter;
+ }
+ result |= Int64(value & 0x1f) << shift;
+ shift += 5;
+ index += 1;
+
+ // We've reached the final 5-bit chunk for this value, so return.
+ // We also return the index, which represents the starting index of the
+ // next value to decode.
+ if ((value & 0x20) == 0) {
+ return (result, index);
+ }
+ }
+
+ // If we've run out of encoded characters without finding an empty 6th bit,
+ // something has gone wrong.
+ throw DecodeError.extraContinueBit;
+ }
+
+ private func decodeHeader(
+ encoded: String
+ ) throws -> (header: CompressionParameters, index: Int) {
+ // If the data has a header, the first value is expected to be the header version
+ // and the second value is compressed metadata containing precision and dimension information.
+ let (headerVersion, metadataIndex) = try self.decodeUnsignedValue(encoded: encoded, startIndex: 0);
+ if (headerVersion != FlexiblePolylineFormatVersion) {
+ throw DecodeError.invalidHeaderVersion;
+ }
+ let (metadata, nextIndex) = try self.decodeUnsignedValue(
+ encoded: encoded,
+ startIndex: metadataIndex
+ );
+ let header = CompressionParameters(
+ precisionLngLat: Int(metadata & 0x0f),
+ precisionThirdDimension: Int(metadata >> 7) & 0x0f,
+ thirdDimension: ThirdDimension(rawValue: Int((metadata >> 4)) & 0x07)!
+ );
+ return ( header: header, index: nextIndex );
+ }
+
+
+ func decode(
+ encoded: String,
+ encodePrecision: Int = 0
+ ) throws -> (lngLatArray: Array>, header: CompressionParameters) {
+ // Empty input strings are considered invalid.
+ if (encoded.count == 0) {
+ throw DecodeError.emptyInput;
+ }
+
+ // If the data doesn't have a header, default to the passed-in precision and no 3rd dimension.
+ var header = CompressionParameters(
+ precisionLngLat: encodePrecision,
+ precisionThirdDimension: 0,
+ thirdDimension: ThirdDimension.None
+ );
+
+ // Track the index of the next character to decode from the encoded string.
+ var index = 0;
+
+ if (self.containsHeader) {
+ (header, index) = try self.decodeHeader(encoded: encoded);
+ }
+
+ let numDimensions = (header.thirdDimension != ThirdDimension.None) ? 3 : 2;
+ var outputLngLatArray: Array> = [];
+
+ // The data either contains lat/lng or lat/lng/z values that will be decoded.
+ // precisionDivisors are the divisors needed to convert the values from integers
+ // back to floating-point.
+ let precisionDivisors:[Double] = [
+ pow(10.0, Double(header.precisionLngLat)),
+ pow(10.0, Double(header.precisionLngLat)),
+ pow(10.0, Double(header.precisionThirdDimension))
+ ];
+
+ // maxAllowedValues are the maximum absolute values allowed for lat/lng/z. This is used for
+ // error-checking the coordinate values as they're being decoded.
+ let maxAllowedValues = [90.0, 180.0, Double.greatestFiniteMagnitude];
+
+ // While decoding, we want to switch from lat/lng/z to lng/lat/z, so this index tells us
+ // what position to put the dimension in for the resulting coordinate.
+ let resultDimensionIndex = [1, 0, 2];
+
+ // Decoded values are deltas from the previous coordinate values, so track the previous values.
+ var lastScaledCoordinate:[Int64] = [0, 0, 0];
+
+ // Keep decoding until we reach the end of the string.
+ while (index < encoded.count) {
+ // Each time through the loop we'll decode one full coordinate.
+ var coordinate: [Double] = (numDimensions == 2) ? [0.0, 0.0] : [0.0, 0.0, 0.0];
+ var deltaValue:Int64 = 0;
+
+ // Decode each dimension for the coordinate.
+ for dimension in 0...(numDimensions - 1) {
+ if (index >= encoded.count) {
+ throw DecodeError.missingCoordinateDimension;
+ }
+
+ (deltaValue, index) = try self.decodeSignedValue(encoded: encoded, startIndex: index);
+ lastScaledCoordinate[dimension] += deltaValue;
+ // Get the final lat/lng/z value by scaling the integer back down based on the number of
+ // digits of precision.
+ let value =
+ Double(lastScaledCoordinate[dimension]) / precisionDivisors[dimension];
+ if (abs(value) > maxAllowedValues[dimension]) {
+ throw DecodeError.invalidCoordinateValue;
+ }
+ coordinate[resultDimensionIndex[dimension]] = value;
+ }
+ outputLngLatArray.append(coordinate);
+ }
+
+ return (outputLngLatArray, header);
+ }
+
+}
diff --git a/swift/Polyline/Sources/Polyline/Algorithm/Encoder.swift b/swift/Polyline/Sources/Polyline/Algorithm/Encoder.swift
new file mode 100644
index 0000000..cb1079b
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/Algorithm/Encoder.swift
@@ -0,0 +1,159 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+// This class implements both the Encoded Polyline Algorithm Format
+// (https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
+// and the Flexible-Polyline variation of the algorithm (https://github.com/heremaps/flexible-polyline).
+
+// This implementation has two differences to improve usability:
+// - It uses well-defined rounding to ensure deterministic results across all programming languages.
+// The Flexible-Polyline algorithm definition says to use the rounding rules of the programming
+// language, but this can cause inconsistent rounding depending on what language happens to be used
+// on both the encoding and decoding sides.
+// - It caps the max encoding/decoding precision to 11 decimal places (1 micrometer), because 12+ places can
+// lose precision when using 64-bit floating-point numbers to store integers.
+
+import Foundation
+
+class PolylineEncoder {
+ // encodingTable is a lookup table that converts values from 0x00-0x3F
+ // to the appropriate encoded ASCII character. Polyline and Flexible-Polyline
+ // use different character encodings.
+ let encodingTable: String;
+
+ // includeHeader is true if the format includes a header (Flexible-Polyline),
+ // and false if it doesn't (Polyline).
+ let includeHeader: Bool;
+
+ init(encodingTable: String, includeHeader: Bool) {
+ self.encodingTable = encodingTable;
+ self.includeHeader = includeHeader;
+ }
+
+ // The original polyline algorithm supposedly uses "round to nearest, ties away from 0"
+ // for its rounding rule. Flexible-polyline uses the rounding rules of the implementing
+ // language. Our generalized implementation will use the "round to nearest, ties away from 0"
+ // rule for all languages to keep the encoding deterministic across implementations.
+ private func polylineRound(_ value: Double) -> Int64 {
+ let rounded = floor(abs(value) + 0.5);
+ return (value >= 0.0) ? Int64(rounded) : Int64(-rounded);
+ }
+
+ func encode(
+ lngLatArray: Array>,
+ precision: Int,
+ thirdDim: ThirdDimension = ThirdDimension.None,
+ thirdDimPrecision: Int = 0
+ ) throws -> String {
+ if (precision < 0 || precision > 11) {
+ throw EncodeError.invalidPrecisionValue;
+ }
+ if (thirdDimPrecision < 0 || thirdDimPrecision > 11) {
+ throw EncodeError.invalidPrecisionValue;
+ }
+
+ if (lngLatArray.count == 0) {
+ return "";
+ }
+
+ let numDimensions = (thirdDim != ThirdDimension.None) ? 3 : 2;
+
+ // The data will either encode lat/lng or lat/lng/z values.
+ // precisionMultipliers are the multipliers needed to convert the values
+ // from floating-point to scaled integers.
+ let precisionMultipliers = [
+ pow(10.0, Double(precision)),
+ pow(10.0, Double(precision)),
+ pow(10.0, Double(thirdDimPrecision))
+ ];
+
+ // While encoding, we want to switch from lng/lat/z to lat/lng/z, so this index tells us
+ // what index to grab from the input coordinate when encoding each dimension.
+ let inputDimensionIndex = [1, 0, 2];
+
+ // maxAllowedValues are the maximum absolute values allowed for lat/lng/z. This is used for
+ // error-checking the coordinate values as they're being encoded.
+ let maxAllowedValues = [90.0, 180.0, Double.greatestFiniteMagnitude];
+
+ // Encoded values are deltas from the previous coordinate values, so track the previous lat/lng/z values.
+ var lastScaledCoordinate:[Int64] = [0, 0, 0];
+
+ var output = "";
+
+ // Flexible-polyline starts with an encoded header that contains precision and dimension metadata.
+ if (self.includeHeader) {
+ output = self.encodeHeader(precision: precision, thirdDim: thirdDim, thirdDimPrecision: thirdDimPrecision);
+ }
+
+ for coordinate in lngLatArray {
+ if (coordinate.count != numDimensions) {
+ throw EncodeError.inconsistentCoordinateDimensions;
+ }
+
+ for dimension in 0...(numDimensions - 1) {
+ // Even though our input data is in lng/lat/z order, this is where we grab them in
+ // lat/lng/z order for encoding.
+ let inputValue = coordinate[inputDimensionIndex[dimension]];
+ // While looping through, also verify the input data is valid
+ if (abs(inputValue) > maxAllowedValues[dimension]) {
+ throw EncodeError.invalidCoordinateValue;
+ }
+ // Scale the value based on the number of digits of precision, encode the delta between
+ // it and the previous value to the output, and track it as the previous value for encoding
+ // the next delta.
+ let scaledValue = self.polylineRound((inputValue * precisionMultipliers[dimension]));
+ output += self.encodeSignedValue(scaledValue - lastScaledCoordinate[dimension]);
+ lastScaledCoordinate[dimension] = scaledValue;
+ }
+ }
+
+ return output;
+ }
+
+ private func encodeHeader(
+ precision: Int,
+ thirdDim: ThirdDimension,
+ thirdDimPrecision: Int
+ ) -> String {
+ // Combine all the metadata about the encoded data into a single value for the header.
+ let metadataValue =
+ (thirdDimPrecision << 7) | (thirdDim.rawValue << 4) | precision;
+ return (
+ self.encodeUnsignedValue(Int64(FlexiblePolylineFormatVersion)) +
+ self.encodeUnsignedValue(Int64(metadataValue))
+ );
+ }
+
+ // Given a single input unsigned scaled value, this encodes into a series of
+ // ASCII characters. The flexible-polyline algorithm uses this directly to encode
+ // the header bytes, since those are known not to need a sign bit.
+ private func encodeUnsignedValue(_ value: Int64) -> String {
+ var encodedString = "";
+ var remainingValue = value;
+ // Loop through each 5-bit chunk in the value, add a 6th bit if there
+ // will be additional chunks, and encode to an ASCII value.
+ while (remainingValue > 0x1f) {
+ let chunk = Int(remainingValue & 0x1f) | 0x20;
+ let encodedChar = self.encodingTable[self.encodingTable.index(self.encodingTable.startIndex, offsetBy: chunk)];
+ encodedString += [encodedChar];
+ remainingValue >>= 5;
+ }
+ // For the last chunk, set the 6th bit to 0 (since there are no more chunks) and encode it.
+ let finalEncodedChar = self.encodingTable[self.encodingTable.index(self.encodingTable.startIndex, offsetBy: Int(remainingValue))];
+ return encodedString + [finalEncodedChar];
+ }
+
+ // Given a single input signed scaled value, this encodes into a series of
+ // ASCII characters.
+ private func encodeSignedValue(_ value: Int64) -> String {
+ var unsignedValue = value;
+ // Shift the value over by 1 bit to make room for the sign bit at the end.
+ unsignedValue <<= 1;
+ // If the input value is negative, flip all the bits, including the sign bit.
+ if (value < 0) {
+ unsignedValue = ~unsignedValue;
+ }
+
+ return self.encodeUnsignedValue(unsignedValue);
+ }
+}
diff --git a/swift/Polyline/Sources/Polyline/Compressors/EncodedPolyline.swift b/swift/Polyline/Sources/Polyline/Compressors/EncodedPolyline.swift
new file mode 100644
index 0000000..69f36aa
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/Compressors/EncodedPolyline.swift
@@ -0,0 +1,83 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+// This class implements the Encoded Polyline Algorithm Format
+// (https://developers.google.com/maps/documentation/utilities/polylinealgorithm).
+// This algorithm is commonly used with either 5 or 6 bits of precision.
+// To improve usability and decrease user error, we present Polyline5 and Polyline6
+// as two distinct compression algorithms.
+
+import Foundation
+
+class EncodedPolyline: DataCompressor {
+ let precision: Int;
+
+ // The original Encoded Polyline algorithm doesn't support having a header on the encoded data.
+ let DataContainsHeader = false;
+
+ let PolylineEncodingTable: String =
+ "?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+ // The lookup table contains conversion values for ASCII characters 0-127.
+ // Only the characters listed in the encoding table will contain valid
+ // decoding entries below.
+ let PolylineDecodingTable = [
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
+ 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
+ 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52,
+ 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, -1,
+ ];
+ let encoder : PolylineEncoder;
+ let decoder : PolylineDecoder;
+
+ init(precision: Int) {
+ self.precision = precision;
+ self.encoder = PolylineEncoder(
+ encodingTable: PolylineEncodingTable,
+ includeHeader: DataContainsHeader
+ );
+ self.decoder = PolylineDecoder(
+ decodingTable: PolylineDecodingTable,
+ containsHeader: DataContainsHeader
+ );
+ super.init();
+ }
+
+ override func compressLngLatArray(
+ lngLatArray: Array>,
+ parameters: CompressionParameters
+ ) throws -> String {
+ return try self.encoder.encode(lngLatArray: lngLatArray, precision: self.precision);
+ }
+
+ override func decompressLngLatArray(
+ compressedData: String
+ ) throws -> (Array>, CompressionParameters) {
+ let (lngLatArray, header) = try self.decoder.decode(
+ encoded: compressedData,
+ encodePrecision: self.precision
+ );
+ let compressionParameters = CompressionParameters(precisionLngLat: header.precisionLngLat);
+ return (lngLatArray, compressionParameters);
+ }
+}
+
+// Polyline5 and Polyline6 encodes/decodes compressed data with 5 or 6 bits of precision respectively.
+// While the underlying Polyline implementation allows for an arbitrary
+// number of bits of precision to be encoded / decoded, location service providers seem
+// to only choose 5 or 6 bits of precision, so those are the two algorithms that we'll explicitly offer here.
+
+class Polyline5 : EncodedPolyline {
+ init() {
+ super.init(precision: 5);
+ }
+}
+
+class Polyline6 : EncodedPolyline {
+ init() {
+ super.init(precision: 6);
+ }
+}
diff --git a/swift/Polyline/Sources/Polyline/Compressors/FlexiblePolyline.swift b/swift/Polyline/Sources/Polyline/Compressors/FlexiblePolyline.swift
new file mode 100644
index 0000000..4b91814
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/Compressors/FlexiblePolyline.swift
@@ -0,0 +1,61 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+// This class implements the Flexible-Polyline variation of the
+// Encoded Polyline algorithm (https://github.com/heremaps/flexible-polyline).
+// The algorithm supports both 2D and 3D data.
+
+import Foundation;
+
+class FlexiblePolyline : DataCompressor {
+ let DataContainsHeader = true;
+ let FlexPolylineEncodingTable =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ // The lookup table contains conversion values for ASCII characters 0-127.
+ // Only the characters listed in the encoding table will contain valid
+ // decoding entries below.
+ let FlexPolylineDecodingTable = [
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, 52, 53, 54, 55, 56, 57, 58, 59, 60,
+ 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
+ 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, -1,
+ 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44,
+ 45, 46, 47, 48, 49, 50, 51,
+ ];
+
+ let encoder : PolylineEncoder;
+ let decoder : PolylineDecoder;
+
+ override init() {
+ self.encoder = PolylineEncoder(
+ encodingTable: self.FlexPolylineEncodingTable,
+ includeHeader: self.DataContainsHeader
+ );
+ self.decoder = PolylineDecoder(
+ decodingTable: self.FlexPolylineDecodingTable,
+ containsHeader: self.DataContainsHeader
+ );
+ super.init();
+ }
+
+ override func compressLngLatArray(
+ lngLatArray: Array>,
+ parameters: CompressionParameters
+ ) throws -> String {
+ return try self.encoder.encode(
+ lngLatArray: lngLatArray,
+ precision: parameters.precisionLngLat,
+ thirdDim: parameters.thirdDimension,
+ thirdDimPrecision: parameters.precisionThirdDimension
+ );
+ }
+
+ override func decompressLngLatArray(
+ compressedData: String
+ ) throws -> (Array>, CompressionParameters) {
+ let (lngLatArray, header) = try self.decoder.decode(encoded: compressedData);
+
+ return (lngLatArray, header);
+ }
+}
diff --git a/swift/Polyline/Sources/Polyline/DataCompressor.swift b/swift/Polyline/Sources/Polyline/DataCompressor.swift
new file mode 100644
index 0000000..49a24a3
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/DataCompressor.swift
@@ -0,0 +1,260 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import Foundation;
+
+// DataCompressor is an abstract base class that defines the interface for
+// encoding/decoding compressed coordinate arrays. The coordinate arrays represent either
+// LineString ("polyline") or Polygon geometry.
+// To make this compressed data easy to use with MapLibre, DataCompressor provides
+// methods for decoding the data into different types of GeoJSON outputs:
+// - decodeToLineStringFeature / decodeToPolygonFeature:
+// These produce a GeoJSON Feature object that can be directly passed into MapLibre as a geojson source.
+// - decodeToLineString / decodeToPolygon:
+// These produce a GeoJSON Geometry object that can be manually assembled into a Feature to pass
+// into MapLibre as a geojson source.
+
+// Concrete implementations of this class are expected to implement the following APIs:
+// - compressLngLatArray(lngLatArray, compressionParameters) -> compressedData
+// - decompressLngLatArray(compressedData) -> [lngLatArray, compressionParameters]
+
+class DataCompressor {
+ // Encode an array of LngLat data into a string of compressed data.
+ // The coordinates may optionally have a third dimension of data.
+ func compressLngLatArray(
+ lngLatArray: Array>,
+ parameters: CompressionParameters
+ ) throws -> String {
+ return "";
+ }
+
+ // Decode a string of compressed data into an array of LngLat data.
+ // The coordinates may optionally have a third dimension of data.
+ func decompressLngLatArray(
+ compressedData: String
+ ) throws -> (Array>, CompressionParameters) {
+ return ([], CompressionParameters(precisionLngLat: DefaultPrecision, precisionThirdDimension: 0, thirdDimension: ThirdDimension.None));
+ }
+
+ // Helper method to determine whether the polygon is wound in CCW (counterclockwise) or CW (clockwise) order.
+ private func polygonIsCounterClockwise(
+ lngLatArray: Array>
+ ) -> Bool {
+ // If the data isn't a polygon, then it can't be a counter-clockwise polygon.
+ // (A polygon requires at least 3 unique points and a 4th last point that matches the first)
+ if (lngLatArray.count < 4) {
+ return false;
+ }
+
+ // To determine if a polygon has a counterclockwise winding order, all we need to
+ // do is calculate the area of the polygon.
+ // If the area is positive, it's counterclockwise.
+ // If the area is negative, it's clockwise.
+ // If the area is 0, it's neither, so we'll still return false for counterclockwise.
+ // This implementation currently assumes that only 2D winding order is important and
+ // ignores any optional third dimension.
+ var area = 0.0;
+ for idx in 0...(lngLatArray.count - 2) {
+ let x1 = lngLatArray[idx][0];
+ let y1 = lngLatArray[idx][1];
+ let x2 = lngLatArray[idx + 1][0];
+ let y2 = lngLatArray[idx + 1][1];
+ area += x1 * y2 - x2 * y1;
+ }
+ // If we needed the actual area value, we should divide by 2 here, but since we only
+ // need to check the sign, we can skip the division.
+ return area > 0;
+ }
+
+ // Helper method to determine if two LngLat positions are equivalent within a given epsilon range.
+ private func positionsAreEquivalent(
+ _ pos1: Array,
+ _ pos2: Array
+ ) -> Bool {
+ // Verify that the two positions are equal within an epsilon.
+ // This epsilon was picked because most compressed data uses <= 6 digits of precision,
+ // so this epsilon is large enough to detect intentionally different data, and small
+ // enough to detect equivalency for values that just have compression artifact drift.
+ let epsilon = 0.000001;
+ if (pos1.count != pos2.count) {
+ return false;
+ }
+ // Loop through longitude, latitude, and optional 3rd dimension to make sure each one is equivalent.
+ for idx in 0...(pos1.count - 1) {
+ if (abs(pos1[idx] - pos2[idx]) >= epsilon) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private func decodeLineString(
+ _ compressedData: String
+ ) throws -> (String, CompressionParameters) {
+ let (decodedLine, compressionParameters) =
+ try self.decompressLngLatArray(compressedData: compressedData);
+ // Validate that the result is a valid GeoJSON LineString per the RFC 7946 GeoJSON spec:
+ // "The 'coordinates' member is an array of two or more positions"
+ if (decodedLine.count < 2) {
+ throw GeoJsonError.invalidLineStringLength;
+ }
+ return (
+ """
+ {
+ "type": "LineString",
+ "coordinates": \(decodedLine),
+ }
+ """,
+ compressionParameters
+ );
+ }
+
+ private func decodePolygon(
+ _ compressedData: Array
+ ) throws -> (String, CompressionParameters) {
+ var decodedPolygon : Array>> = [];
+ var shouldBeCounterclockwise = true; // The first ring of a polygon should be counterclockwise
+ var compressionParameters: CompressionParameters = CompressionParameters();
+ for ring in compressedData {
+ var (decodedRing, ringCompressionParameters) = try self.decompressLngLatArray(compressedData: ring);
+
+ // Validate that the result is a valid GeoJSON Polygon linear ring per the RFC 7946 GeoJSON spec.
+
+ // 1. "A linear ring is a closed LineString with 4 or more positions."
+ if (decodedRing.count < 4) {
+ throw GeoJsonError.invalidPolygonLength;
+ }
+
+ // 2. "The first and last positions are equivalent, and they MUST contain identical values;
+ // their representation SHOULD also be identical."
+ // We validate equivalency within a small epsilon.
+ if (
+ !self.positionsAreEquivalent(
+ decodedRing[0],
+ decodedRing[decodedRing.count - 1]
+ )
+ ) {
+ throw GeoJsonError.invalidPolygonClosure;
+ }
+
+ // 3. "A linear ring MUST follow the right-hand rule with respect to the area it bounds,
+ // i.e., exterior rings are counterclockwise, and holes are clockwise."
+ // "Note: the [GJ2008] specification did not discuss linear ring winding
+ // order. For backwards compatibility, parsers SHOULD NOT reject
+ // Polygons that do not follow the right-hand rule."
+ // "For Polygons with more than one of these rings, the first MUST be
+ // the exterior ring, and any others MUST be interior rings. The
+ // exterior ring bounds the surface, and the interior rings (if
+ // present) bound holes within the surface."
+
+ // With all this taken together, we should enforce the winding order as opposed to just
+ // validating it.
+ if (
+ shouldBeCounterclockwise != self.polygonIsCounterClockwise(lngLatArray: decodedRing)
+ ) {
+ decodedRing.reverse();
+ }
+
+ decodedPolygon.append(decodedRing);
+
+ // Set compressionParameter metadata to whatever the last compression parameters were that were used.
+ // This may need to have more complicated logic at some point if different rings have different compression
+ // parameters and we want to capture all of them.
+ compressionParameters = ringCompressionParameters;
+
+ // All rings after the first should be clockwise.
+ shouldBeCounterclockwise = false;
+ }
+ return (
+ """
+ {
+ "type": "Polygon",
+ "coordinates": \(decodedPolygon),
+ }
+ """,
+ compressionParameters
+ );
+ }
+
+ private func compressionParametersToGeoJsonProperties(
+ parameters: CompressionParameters
+ ) -> String {
+ switch (parameters.thirdDimension) {
+ case ThirdDimension.Level:
+ return """
+ {
+ "precision": \(parameters.precisionLngLat),
+ "thirdDimensionPrecision": \(parameters.precisionThirdDimension),
+ "thirdDimensionType": "level",
+ }
+ """;
+ case ThirdDimension.Elevation:
+ return """
+ {
+ "precision": \(parameters.precisionLngLat),
+ "thirdDimensionPrecision": \(parameters.precisionThirdDimension),
+ "thirdDimensionType": "elevation",
+ }
+ """;
+ case ThirdDimension.Altitude:
+ return """
+ {
+ "precision": \(parameters.precisionLngLat),
+ "thirdDimensionPrecision": \(parameters.precisionThirdDimension),
+ "thirdDimensionType": "altitude",
+ }
+ """;
+ default:
+ return """
+ {
+ "precision": \(parameters.precisionLngLat)
+ }
+ """;
+ }
+ }
+
+ func encodeFromLngLatArray(
+ lngLatArray: Array>,
+ parameters: CompressionParameters
+ ) throws -> String {
+ return try self.compressLngLatArray(lngLatArray: lngLatArray, parameters: parameters);
+ }
+
+ func decodeToLngLatArray(compressedData: String) throws -> Array> {
+ let (decodedLngLatArray, _) = try self.decompressLngLatArray(compressedData: compressedData);
+
+ return decodedLngLatArray;
+ }
+
+ func decodeToLineString(compressedData: String) throws -> String {
+ let (lineString, _) = try self.decodeLineString(compressedData);
+ return lineString;
+ }
+
+ func decodeToPolygon(compressedData: Array) throws -> String {
+ let (polygon, _) = try self.decodePolygon(compressedData);
+ return polygon;
+ }
+
+ func decodeToLineStringFeature(compressedData: String) throws -> String {
+ let (lineString, compressionParameters) = try self.decodeLineString(compressedData);
+ return """
+ {
+ "type": "Feature",
+ "geometry": \(lineString),
+ "properties": \(self.compressionParametersToGeoJsonProperties(parameters: compressionParameters)),
+ }
+ """;
+ }
+
+ func decodeToPolygonFeature(compressedData: Array) throws -> String {
+ let (polygon, compressionParameters) = try self.decodePolygon(compressedData);
+ return """
+ {
+ "type": "Feature",
+ "geometry": \(polygon),
+ "properties": \(self.compressionParametersToGeoJsonProperties(parameters: compressionParameters)),
+ }
+ """;
+ }
+}
diff --git a/swift/Polyline/Sources/Polyline/Polyline.swift b/swift/Polyline/Sources/Polyline/Polyline.swift
new file mode 100644
index 0000000..6c2966e
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/Polyline.swift
@@ -0,0 +1,253 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import Foundation
+
+// The default algorithm is FlexiblePolyline. This was selected as it is the newest and most flexible format
+// of the different decoding types supported.
+private var compressor: DataCompressor = FlexiblePolyline();
+
+/** Get the currently-selected compression algorithm.
+ * @returns The current compression algorithm.
+ */
+func getCompressionAlgorithm() -> CompressionAlgorithm {
+ if (compressor is Polyline5) {
+ return CompressionAlgorithm.Polyline5;
+ }
+ if (compressor is Polyline6) {
+ return CompressionAlgorithm.Polyline6;
+ }
+
+ return CompressionAlgorithm.FlexiblePolyline;
+}
+
+/** Set the compression algorithm to use for subsequent encode/decode calls.
+ * @param compressionType The compression algorithm to use.
+ * @throws Error() if an invalid compression algorithm is specified.
+ */
+func setCompressionAlgorithm(_ compressionType: CompressionAlgorithm = .FlexiblePolyline) {
+ switch (compressionType) {
+ case CompressionAlgorithm.Polyline5:
+ if (!(compressor is Polyline5)) {
+ compressor = Polyline5();
+ }
+ case CompressionAlgorithm.Polyline6:
+ if (!(compressor is Polyline6)) {
+ compressor = Polyline6();
+ }
+ default:
+ if (!(compressor is FlexiblePolyline)) {
+ compressor = FlexiblePolyline();
+ }
+ }
+}
+
+/** Encode the provided array of coordinate values into an encoded string.
+ * @remarks
+ * This takes in an array of two-dimensional or three-dimensional positions and encodes them into
+ * the currently-selected compression format.
+ * Example of 2D input data:
+ * ```typescript
+ * [ [5.0, 0.0], [10.0, 5.0], [10.0, 10.0], ]
+ * ```
+ * Example of 3D input data:
+ * ```typescript
+ * [ [5.0, 0.0, 200.0], [10.0, 5.0, 200.0], [10.0, 10.0, 205.0], ]
+ * ```
+ * @param lngLatArray An array of lng/lat positions to encode. The positions may contain an optional 3rd dimension.
+ * @param parameters Optional compression parameters. These are currently only used by the FlexiblePolyline algorithm.
+ * @returns An encoded string containing the compressed coordinate values.
+ * @throws Error() if the input data contains no coordinate pairs,
+ * latitude values outside of [-90, 90], longitude values outside of [-180, 180],
+ * data that isn't 2-dimensional or 3-dimensional, or data that is 3-dimensional with a compressor that doesn't support 3D data.
+ */
+func encodeFromLngLatArray(
+ lngLatArray: Array>,
+ parameters: CompressionParameters = CompressionParameters()
+) throws -> String {
+ return try compressor.encodeFromLngLatArray(lngLatArray: lngLatArray, parameters: parameters);
+}
+
+/** Decode the provided encoded data string into an array of coordinate values.
+ * @remarks
+ * Note that this method returns a raw array of coordinate values, which cannot be used as a MapLibre source
+ * without first embedding it into a GeoJSON Feature. If you want to add the decoded data as a MapLibre source,
+ * use either {@link decodeToLineStringFeature} or {@link decodeToPolygonFeature} instead.
+ * Only use this method when you want to use the coordinate data directly.
+ * @param compressedData The encoded data string to decode. The data is expected to have valid lat/lng values.
+ * @returns An array of coordinate value arrays.
+ * @throws Error() if the encodedData contains invalid characters, no coordinate pairs,
+ * latitude values outside of [-90, 90], or longitude values outside of [-180, 180].
+ * @example
+ * An example of decoded data:
+ * ```typescript
+ * [
+ * [5.0, 0.0],
+ * [10.0, 5.0],
+ * [10.0, 10.0],
+ * ]
+ * ```
+ */
+func decodeToLngLatArray(
+ _ encodedData: String
+) throws -> Array> {
+ return try compressor.decodeToLngLatArray(compressedData: encodedData);
+}
+
+/** Decode the provided encoded data string into a GeoJSON LineString.
+ * @remarks
+ * Note that this method returns a LineString, which cannot be used as a MapLibre source without first embedding it
+ * into a GeoJSON Feature. If you want to add the LineString as a MapLibre source, use {@link decodeToLineStringFeature} instead.
+ * Only use this method when you plan to manipulate the LineString further as opposed to using it directly as a source.
+ * @param encodedData The encoded data string to decode. The data is expected to have a minimum of two
+ * coordinate pairs with valid lat/lng values.
+ * @returns A GeoJSON LineString representing the decoded data.
+ * @throws Error() if the encodedData contains invalid characters, < 2 coordinate pairs,
+ * latitude values outside of [-90, 90], or longitude values outside of [-180, 180].
+ * @example
+ * An example of a decoded LineString:
+ * ```json
+ * {
+ * "type": "LineString",
+ * "coordinates": [
+ * [5.0, 0.0],
+ * [10.0, 5.0],
+ * [10.0, 10.0],
+ * ]
+ * }
+ * ```
+ */
+func decodeToLineString(_ encodedData: String) throws -> String {
+ return try compressor.decodeToLineString(compressedData: encodedData);
+}
+
+/** Decode the provided encoded data string into a GeoJSON Polygon.
+ * @remarks
+ * Note that this method returns a Polygon, which cannot be used as a MapLibre source without first embedding it
+ * into a GeoJSON Feature. If you want to add the Polygon as a MapLibre source, use {@link decodeToPolygonFeature} instead.
+ * Only use this method when you plan to manipulate the Polygon further as opposed to using it directly as a source.
+ * @param encodedData An array of encoded data strings to decode. This is an array instead of a single string
+ * because polygons can consist of multiple rings of compressed data. The first entry will be treated as the
+ * outer ring and the remaining entries will be treated as inner rings. Each input ring can be wound either
+ * clockwise or counterclockwise; they will get rewound to be GeoJSON-compliant in the output. Each ring is
+ * expected to have a minimum of four coordinate pairs with valid lat/lng data, and the last coordinate pair
+ * must match the first to make an explicit ring.
+ * @returns A GeoJSON Polygon representing the decoded data. The first entry in the output coordinates
+ * represents the outer ring and any remaining entries represent inner rings.
+ * @throws Error() if the encodedData contains invalid characters, < 4 coordinate pairs, first/last coordinates that
+ * aren't approximately equal, latitude values outside of [-90, 90], or longitude values outside of [-180, 180].
+ * @example
+ * An example of a decoded Polygon:
+ * ```json
+ * {
+ * "type": "Polygon",
+ * "coordinates": [
+ * [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], // outer ring
+ * [[2, 2], [2, 8], [8 , 8 ], [8 , 2], [2, 2]], // inner ring
+ * [[4, 4], [4, 6], [6 , 6 ], [6 , 4], [4, 4]] // inner ring
+ * ]
+ * }
+ * ```
+ */
+func decodeToPolygon(_ encodedData: Array) throws -> String {
+ return try compressor.decodeToPolygon(compressedData: encodedData);
+}
+
+/** Decode the provided encoded data string into a GeoJSON Feature containing a LineString.
+ * @param encodedData The encoded data string to decode. The data is expected to have a minimum of two
+ * coordinate pairs with valid lat/lng values.
+ * @returns A GeoJSON Feature containing a LineString that represents the decoded data.
+ * @throws Error() if the encodedData contains invalid characters, < 2 coordinate pairs,
+ * latitude values outside of [-90, 90], or longitude values outside of [-180, 180]
+ * @example
+ * An example of a decoded LineString as a Feature:
+ * ```json
+ * {
+ * "type": "Feature",
+ * "properties": {},
+ * "geometry": {
+ * "type": "LineString",
+ * "coordinates": [
+ * [5.0, 0.0],
+ * [10.0, 5.0],
+ * [10.0, 10.0],
+ * ]
+ * }
+ * }
+ * ```
+ * The result of this method can be used with MapLibre's `addSource` to add a named data source or embedded directly
+ * with MapLibre's `addLayer` to both add and render the result:
+ * ```javascript
+ * var decodedGeoJSON = polylineDecoder.decodeToLineStringFeature(encodedRoutePolyline);
+ * map.addLayer({
+ * id: 'route',
+ * type: 'line',
+ * source: {
+ * type: 'geojson',
+ * data: decodedGeoJSON
+ * },
+ * layout: {
+ * 'line-join': 'round',
+ * 'line-cap': 'round'
+ * },
+ * paint: {
+ * 'line-color': '#3887be',
+ * 'line-width': 5,
+ * 'line-opacity': 0.75
+ * }
+ * });
+ * ```
+ */
+func decodeToLineStringFeature(_ encodedData: String) throws -> String {
+ return try compressor.decodeToLineStringFeature(compressedData: encodedData);
+}
+
+/** Decode the provided encoded data string into a GeoJSON Feature containing a Polygon.
+ * @param encodedData An array of encoded data strings to decode. This is an array instead of a single string
+ * because polygons can consist of multiple rings of compressed data. The first entry will be treated as the
+ * outer ring and the remaining entries will be treated as inner rings. Each input ring can be wound either
+ * clockwise or counterclockwise; they will get rewound to be GeoJSON-compliant in the output. Each ring is
+ * expected to have a minimum of four coordinate pairs with valid lat/lng data, and the last coordinate pair
+ * must match the first to make an explicit ring.
+ * @returns A GeoJSON Feature containing a Polygon that represents the decoded data. The first entry in the
+ * output coordinates represents the outer ring and any remaining entries represent inner rings.
+ * @throws Error() if the encodedData contains invalid characters, < 4 coordinate pairs, first/last coordinates that
+ * aren't approximately equal, latitude values outside of [-90, 90], or longitude values outside of [-180, 180].
+ * @example
+ * An example of a decoded Polygon as a Feature:
+ * ```json
+ * {
+ * 'type': 'Feature',
+ * 'properties': {},
+ * 'geometry': {
+ * "type": "Polygon",
+ * "coordinates": [
+ * [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], // outer ring
+ * [[2, 2], [2, 8], [8 , 8 ], [8 , 2], [2, 2]], // inner ring
+ * [[4, 4], [4, 6], [6 , 6 ], [6 , 4], [4, 4]] // inner ring
+ * ]
+ * }
+ * }
+ * ```
+ * The result of this method can be used with MapLibre's `addSource` to add a named data source or embedded directly
+ * with MapLibre's `addLayer` to both add and render the result:
+ * ```javascript
+ * var decodedGeoJSON = polylineDecoder.decodeToPolygonFeature(encodedIsolinePolygons);
+ * map.addLayer({
+ * id: 'isoline',
+ * type: 'fill',
+ * source: {
+ * type: 'geojson',
+ * data: decodedGeoJSON
+ * },
+ * layout: {},
+ * paint: {
+ * 'fill-color': '#FF0000',
+ * 'fill-opacity': 0.6
+ }
+ * });
+ * ```
+ */
+func decodeToPolygonFeature(_ encodedData: Array) throws -> String {
+ return try compressor.decodeToPolygonFeature(compressedData: encodedData);
+}
diff --git a/swift/Polyline/Sources/Polyline/PolylineTypes.swift b/swift/Polyline/Sources/Polyline/PolylineTypes.swift
new file mode 100644
index 0000000..0eb79b2
--- /dev/null
+++ b/swift/Polyline/Sources/Polyline/PolylineTypes.swift
@@ -0,0 +1,95 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import Foundation
+
+/** Defines the default encoding precision for coordinates */
+let DefaultPrecision = 6;
+
+/** The version of flexible-polyline that's supported by this implementation */
+let FlexiblePolylineFormatVersion = 1;
+
+/** Defines the set of compression algorithms that are supported by this library. */
+enum CompressionAlgorithm {
+ /** Encoder/decoder for the [Flexible Polyline](https://github.com/heremaps/flexible-polyline) format. */
+ case FlexiblePolyline
+ /** Encoder/decoder for the [Encoded Polyline Algorithm Format](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
+ * with 5 bits of precision.
+ */
+ case Polyline5
+ /** Encoder/decoder for the [Encoded Polyline Algorithm Format](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
+ * with 6 bits of precision.
+ */
+ case Polyline6
+}
+
+/** Defines how to interpret a third dimension value if it exists. */
+enum ThirdDimension:Int {
+ /** No third dimension specified */
+ case None = 0
+ /** Third dimension is level */
+ case Level = 1
+ /** Third dimension is altitude (height above the Earth's surface) */
+ case Altitude = 2
+ /** Third dimension is elevation (height of the Earth's surface relative to the reference geoid) */
+ case Elevation = 3
+}
+
+/** The optional set of parameters for encoding a set of LngLat coordinates.
+ * Currently, only the FlexiblePolyline algorithm supports these parameters. The Polyline5 / Polyline6
+ * algorithms ignore them, as they don't support 3D data and we've defined them to use
+ * a fixed precision value.
+ */
+struct CompressionParameters {
+ /** The number of decimal places of precision to use for compressing longitude and latitude.
+ */
+ let precisionLngLat: Int;
+ /** The number of decimal places of precision to use for compressing the third dimension of data.
+ */
+ let precisionThirdDimension: Int;
+ /** The type of third dimension data being encoded - none, level, altitude, or elevation.
+ */
+ let thirdDimension: ThirdDimension;
+
+ init(precisionLngLat: Int = DefaultPrecision, precisionThirdDimension: Int = 0, thirdDimension: ThirdDimension = ThirdDimension.None) {
+ self.precisionLngLat = precisionLngLat;
+ self.precisionThirdDimension = precisionThirdDimension;
+ self.thirdDimension = thirdDimension;
+ }
+};
+
+
+enum DecodeError: Error {
+ // Empty input string is considered an error.
+ case emptyInput
+ // Invalid input, the encoded character doesn't exist in the decoding table.
+ case invalidEncodedCharacter
+ // Invalid encoding, the last block contained an extra 0x20 'continue' bit.
+ case extraContinueBit
+ // The decoded header has an unknown version number.
+ case invalidHeaderVersion
+ // The decoded coordinate has invalid lng/lat values.
+ case invalidCoordinateValue
+ // Decoding ended before all the dimensions for a coordinate were decoded.
+ case missingCoordinateDimension
+};
+
+
+enum EncodeError: Error {
+ // Invalid precision value, the valid range is 0 - 11.
+ case invalidPrecisionValue
+ // All the coordinates need to have the same number of dimensions.
+ case inconsistentCoordinateDimensions
+ // Latitude values need to be in [-90, 90] and longitude values need to be in [-180, 180]
+ case invalidCoordinateValue
+};
+
+
+enum GeoJsonError: Error {
+ // LineString coordinate arrays need at least 2 entries (start, end)
+ case invalidLineStringLength
+ // Polygon coordinate arrays need at least 4 entries (v0, v1, v2, v0)
+ case invalidPolygonLength
+ // Polygons need the first and last coordinate to match
+ case invalidPolygonClosure
+}
diff --git a/swift/Polyline/Tests/PolylineTests/PolylineTests.swift b/swift/Polyline/Tests/PolylineTests/PolylineTests.swift
new file mode 100644
index 0000000..4bbf44d
--- /dev/null
+++ b/swift/Polyline/Tests/PolylineTests/PolylineTests.swift
@@ -0,0 +1,764 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import Foundation
+import XCTest
+@testable import Polyline
+
+// Simplified GeoJSON structures for validating the outputs
+
+struct LineString: Decodable {
+ let type: String;
+ let coordinates: [[Double]];
+}
+struct Polygon: Decodable {
+ let type: String;
+ let coordinates: [[[Double]]];
+}
+struct Properties: Decodable {
+ let precision: Int;
+ let thirdDimensionPrecision: Int?;
+ let thirdDimensionType: String?;
+}
+struct LineStringFeature: Decodable {
+ let type: String;
+ let geometry: LineString;
+ let properties: Properties;
+}
+struct PolygonFeature: Decodable {
+ let type: String;
+ let geometry: Polygon;
+ let properties: Properties;
+}
+
+// Tests to validate the polyline library
+
+final class PolylineTests: XCTestCase {
+
+ let algorithms : [CompressionAlgorithm] = [.FlexiblePolyline, .Polyline5, .Polyline6];
+
+ override func setUp() {
+ // Reset the compression algorithm back to the default for each unit test.
+ Polyline.setCompressionAlgorithm();
+ }
+
+ private func validateLineString(geojson: String, coords: [[Double]]) {
+ let geojsonData = geojson.data(using: .utf8)!;
+ let lineString:LineString = try! JSONDecoder().decode(LineString.self, from: geojsonData);
+
+ XCTAssertEqual(lineString.type, "LineString");
+ XCTAssertEqual(lineString.coordinates, coords);
+ }
+
+ private func validatePolygon(geojson: String, coords: [[[Double]]]) {
+ let geojsonData = geojson.data(using: .utf8)!;
+ let polygon:Polygon = try! JSONDecoder().decode(Polygon.self, from: geojsonData);
+
+ XCTAssertEqual(polygon.type, "Polygon");
+ XCTAssertEqual(polygon.coordinates, coords);
+ }
+
+ private func validateProperties(properties: Properties, parameters: CompressionParameters) {
+ XCTAssertEqual(properties.precision, parameters.precisionLngLat);
+ XCTAssertEqual(properties.thirdDimensionPrecision != nil, parameters.thirdDimension != ThirdDimension.None);
+ if (properties.thirdDimensionPrecision != nil) {
+ XCTAssertEqual(properties.thirdDimensionPrecision, parameters.precisionThirdDimension);
+ }
+ XCTAssertEqual(properties.thirdDimensionType != nil, parameters.thirdDimension != ThirdDimension.None);
+ if (properties.thirdDimensionType != nil) {
+ switch properties.thirdDimensionType {
+ case "level":
+ XCTAssertEqual(parameters.thirdDimension, ThirdDimension.Level);
+ case "altitude":
+ XCTAssertEqual(parameters.thirdDimension, ThirdDimension.Altitude);
+ case "elevation":
+ XCTAssertEqual(parameters.thirdDimension, ThirdDimension.Elevation);
+ default:
+ XCTFail("Unknown third dimension type");
+ }
+ XCTAssertEqual(properties.thirdDimensionPrecision, parameters.precisionThirdDimension);
+ }
+ }
+
+ private func validateLineStringFeature(geojson: String, coords: [[Double]], parameters: CompressionParameters) {
+ let geojsonData = geojson.data(using: .utf8)!;
+ let lineStringFeature:LineStringFeature = try! JSONDecoder().decode(LineStringFeature.self, from: geojsonData);
+
+ XCTAssertEqual(lineStringFeature.type, "Feature");
+ XCTAssertEqual(lineStringFeature.geometry.type, "LineString");
+ XCTAssertEqual(lineStringFeature.geometry.coordinates, coords);
+ validateProperties(properties: lineStringFeature.properties, parameters: parameters);
+ }
+
+ private func validatePolygonFeature(geojson: String, coords: [[[Double]]], parameters: CompressionParameters) {
+ let geojsonData = geojson.data(using: .utf8)!;
+ let polygonFeature:PolygonFeature = try! JSONDecoder().decode(PolygonFeature.self, from: geojsonData);
+
+ XCTAssertEqual(polygonFeature.type, "Feature");
+ XCTAssertEqual(polygonFeature.geometry.type, "Polygon");
+ XCTAssertEqual(polygonFeature.geometry.coordinates, coords);
+ validateProperties(properties: polygonFeature.properties, parameters: parameters);
+ }
+
+
+
+ func testDefaultsToFlexiblePolyline() {
+ XCTAssertEqual(Polyline.getCompressionAlgorithm(), .FlexiblePolyline);
+ }
+
+ func testSettingFlexiblePolyline() {
+ // Since we default to FlexiblePolyline first set to something other than FlexiblePolyline
+ Polyline.setCompressionAlgorithm(.Polyline5);
+ // Now set back to FlexiblePolyline
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ XCTAssertEqual(Polyline.getCompressionAlgorithm(), .FlexiblePolyline);
+ }
+
+ // Verify that all of the non-default algorithms can be set correctly
+ func testSettingNonDefaultAlgorithm() {
+ let nonDefaultAlgorithms: [CompressionAlgorithm] = [ .Polyline5, .Polyline6 ];
+
+ for algorithm in nonDefaultAlgorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+ XCTAssertEqual(Polyline.getCompressionAlgorithm(), algorithm);
+ }
+ }
+
+ func testDecodingEmptyDataThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+ XCTAssertThrowsError(try Polyline.decodeToLineString("")) { error in
+ XCTAssertEqual(error as! Polyline.DecodeError, Polyline.DecodeError.emptyInput);
+ };
+ }
+ }
+
+ func testDecodingBadDataThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+ // The characters in the string below are invalid for each of the decoding algorithms.
+ // For polyline5/polyline6, only ?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ are valid.
+ // For flexiblePolyline, only ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ are valid.
+ XCTAssertThrowsError(try Polyline.decodeToLineString("!#$%(*)")) { error in
+ XCTAssertEqual(error as! Polyline.DecodeError, Polyline.DecodeError.invalidEncodedCharacter);
+ };
+
+ }
+ }
+
+ func testEncodingInputPointValuesAreValidated() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ // Longitude too low
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[-181.0, 5.0], [0.0, 0.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.invalidCoordinateValue);
+ };
+
+ // Longitude too high
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[181.0, 5.0], [0.0, 0.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.invalidCoordinateValue);
+ };
+
+ // Latitude too low
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, -91.0], [0.0, 0.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.invalidCoordinateValue);
+ };
+
+ // Latitude too high
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 91.0], [0.0, 0.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.invalidCoordinateValue);
+ };
+
+ }
+ }
+
+ func testEncodingMixedDimensionalityThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ // Mixing 2D and 3D throws error
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 5.0], [10.0, 10.0, 10.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.inconsistentCoordinateDimensions);
+ };
+ // Mixing 3D and 2D throws error
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 5.0, 5.0], [10.0, 10.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.inconsistentCoordinateDimensions);
+ };
+ }
+ }
+
+ func testEncodingUnsupportedDimensionsThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ // 1D throws error
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0], [10.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.inconsistentCoordinateDimensions);
+ };
+ // 4D throws error
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 5.0, 5.0, 5.0], [10.0, 10.0, 10.0, 10.0]])) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.inconsistentCoordinateDimensions);
+ };
+ }
+ }
+
+ func testEncodingEmptyInputProducesEmptyResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ XCTAssertEqual(try Polyline.encodeFromLngLatArray(lngLatArray:[]), "");
+ }
+ }
+
+ func testDecodeToLineStringWithOnePositionThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 5.0]]);
+ XCTAssertThrowsError(try Polyline.decodeToLineString(encodedLine)) { error in
+ XCTAssertEqual(error as! Polyline.GeoJsonError, Polyline.GeoJsonError.invalidLineStringLength);
+ };
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonWithUnderFourPositionsThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 5.0], [10.0, 10.0], [5.0, 5.0]]);
+ XCTAssertThrowsError(try Polyline.decodeToPolygon([encodedLine])) { error in
+ XCTAssertEqual(error as! Polyline.GeoJsonError, Polyline.GeoJsonError.invalidPolygonLength);
+ };
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonWithMismatchedStartEndThrowsError() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: [[5.0, 5.0], [10.0, 10.0], [15.0, 15.0], [20.0, 20.0]]);
+ XCTAssertThrowsError(try Polyline.decodeToPolygon([encodedLine])) { error in
+ XCTAssertEqual(error as! Polyline.GeoJsonError, Polyline.GeoJsonError.invalidPolygonClosure);
+ };
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToLineStringProducesValidResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [[132.0, -67.0], [38.0, 62.0]];
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToLineString(encodedLine);
+
+ validateLineString(geojson:geojson, coords:coords);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToLineStringFeatureProducesValidResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [[132.0, -67.0], [38.0, 62.0]];
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToLineStringFeature(encodedLine);
+ validateLineStringFeature(geojson: geojson, coords: coords, parameters: CompressionParameters(
+ precisionLngLat:(algorithm == .Polyline5) ? 5 : DefaultPrecision));
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonProducesValidResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [[0.0, 0.0], [10.0, 0.0], [5.0, 10.0], [0.0, 0.0]];
+ let encodedRing = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToPolygon([encodedRing]);
+ validatePolygon(geojson:geojson, coords:[coords]);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonFeatureProducesValidResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [[0.0, 0.0], [10.0, 0.0], [5.0, 10.0], [0.0, 0.0]];
+ let encodedRing = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToPolygonFeature([encodedRing]);
+ validatePolygonFeature(geojson: geojson, coords: [coords], parameters: CompressionParameters(
+ precisionLngLat:(algorithm == .Polyline5) ? 5 : DefaultPrecision)
+ );
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonWithCWOuterRingProducesCCWResult() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [10.0, 0.0], [0.0, 0.0]];
+ let encodedRing = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToPolygon([encodedRing]);
+ let ccwCoords = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]];
+ validatePolygon(geojson:geojson, coords:[ccwCoords]);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonWithCCWOuterRingProducesCCWResult() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]];
+ let encodedRing = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToPolygon([encodedRing]);
+ validatePolygon(geojson:geojson, coords:[coords]);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonWithCWInnerRingsProducesCWResult() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let clockwiseCoords = [
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [10.0, 10.0],
+ [0.0, 10.0],
+ [0.0, 0.0],
+ ], // CCW outer ring
+ [
+ [2.0, 2.0],
+ [2.0, 8.0],
+ [8.0, 8.0],
+ [8.0, 2.0],
+ [2.0, 2.0],
+ ], // CW inner ring
+ [
+ [4.0, 4.0],
+ [4.0, 6.0],
+ [6.0, 6.0],
+ [6.0, 4.0],
+ [4.0, 4.0],
+ ], // CW inner ring
+ ];
+ var encodedRings:Array = [];
+ for ring in clockwiseCoords {
+ encodedRings.append(try Polyline.encodeFromLngLatArray(lngLatArray: ring));
+ }
+ let geojson = try Polyline.decodeToPolygon(encodedRings);
+ validatePolygon(geojson:geojson, coords:clockwiseCoords);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToPolygonWithCCWInnerRingsProducesCWResult() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let counterclockwiseCoords = [
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [10.0, 10.0],
+ [0.0, 10.0],
+ [0.0, 0.0],
+ ], // CCW outer ring
+ [
+ [2.0, 2.0],
+ [8.0, 2.0],
+ [8.0, 8.0],
+ [2.0, 8.0],
+ [2.0, 2.0],
+ ], // CCW inner ring
+ [
+ [4.0, 4.0],
+ [6.0, 4.0],
+ [6.0, 6.0],
+ [4.0, 6.0],
+ [4.0, 4.0],
+ ], // CCW inner ring
+ ];
+ var encodedRings:Array = [];
+ for ring in counterclockwiseCoords {
+ encodedRings.append(try Polyline.encodeFromLngLatArray(lngLatArray: ring));
+ }
+ let geojson = try Polyline.decodeToPolygon(encodedRings);
+ let expectedCoords = [
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [10.0, 10.0],
+ [0.0, 10.0],
+ [0.0, 0.0],
+ ], // CCW outer ring
+ [
+ [2.0, 2.0],
+ [2.0, 8.0],
+ [8.0, 8.0],
+ [8.0, 2.0],
+ [2.0, 2.0],
+ ], // CW inner ring
+ [
+ [4.0, 4.0],
+ [4.0, 6.0],
+ [6.0, 6.0],
+ [6.0, 4.0],
+ [4.0, 4.0],
+ ], // CW inner ring
+ ];
+ validatePolygon(geojson:geojson, coords:expectedCoords);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ func testDecodeToLineStringWithRangesOfInputsProducesValidResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [
+ // A few different valid longitude values (positive, zero, negative)
+ [167.0, 5.0],
+ [0.0, 5.0],
+ [-167.0, 5.0],
+ // A few different valid latitude values (positive, zero, negative)
+ [5.0, 87.0],
+ [5.0, 0.0],
+ [5.0, -87.0],
+ // A few different high-precision values
+ [123.45678, 76.54321],
+ [-123.45678, -76.54321],
+ ];
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToLineString(encodedLine);
+
+ validateLineString(geojson:geojson, coords:coords);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+
+ func testDecodeToPolygonWithRangesOfInputsProducesValidResults() {
+ for algorithm in algorithms {
+ Polyline.setCompressionAlgorithm(algorithm);
+
+ do {
+ let coords = [
+ // A few different valid longitude values (positive, zero, negative)
+ [167.0, 5.0],
+ [0.0, 5.0],
+ [-167.0, 5.0],
+ // A few different valid latitude values (positive, zero, negative)
+ [5.0, 87.0],
+ [5.0, 0.0],
+ [5.0, -87.0],
+ // A few different high-precision values
+ [123.45678, 76.54321],
+ [-123.45678, -76.54321],
+ // Close the polygon ring
+ [167.0, 5.0],
+ ];
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords);
+ let geojson = try Polyline.decodeToPolygon([encodedLine]);
+ validatePolygon(geojson:geojson, coords:[coords]);
+ }
+ catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+
+ // The following tests use hard-coded compressed data because we want them to contain invalid values and our
+ // encoding method would prevent that. The compressed data was generated by calling encodeFromLngLatArray with the
+ // input validation temporarily disabled.
+
+ func testFlexiblePolylineDecodeInvalidValuesThrowsError() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let invalidStrings = [
+ "AGgsmytFg0lxJ_rmytF_zlxJ", // Header version = 0
+ "CGgsmytFg0lxJ_rmytF_zlxJ", // Header version = 2
+ ];
+ for invalidString in invalidStrings {
+ XCTAssertThrowsError(try Polyline.decodeToLngLatArray(invalidString)) { error in
+ XCTAssertEqual(error as! Polyline.DecodeError, Polyline.DecodeError.invalidHeaderVersion);
+ };
+ }
+ }
+
+ func testFlexiblePolylineDecodeInvalidHeaderThrowsError() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let invalidStrings = [
+ "BGg0lxJ_zrn5K_zlxJg0rn5K", // [[-181, 5], [0, 0]] - longitude too low
+ "BGg0lxJg0rn5K_zlxJ_zrn5K", // [[181, 5], [0, 0]] - longitude too high
+ "BG_rmytFg0lxJgsmytF_zlxJ", // [[5, -91], [0, 0]] - latitude too low
+ "BGgsmytFg0lxJ_rmytF_zlxJ", // [[5, 91], [0, 0]] - latitude too high
+ ];
+ for invalidString in invalidStrings {
+ XCTAssertThrowsError(try Polyline.decodeToLngLatArray(invalidString)) { error in
+ XCTAssertEqual(error as! Polyline.DecodeError, Polyline.DecodeError.invalidCoordinateValue);
+ };
+ }
+ }
+
+ func testPolyline5DecodeInvalidValuesThrowsError() {
+ Polyline.setCompressionAlgorithm(.Polyline5);
+ let invalidStrings = [
+ "_qo]~pvoa@~po]_qvoa@", // [[-181, 5], [0, 0]] - longitude too low
+ "_qo]_qvoa@~po]~pvoa@", // [[181, 5], [0, 0]] - longitude too high
+ "~lljP_qo]_mljP~po]", // [[5, -91], [0, 0]] - latitude too low
+ "_mljP_qo]~lljP~po]", // [[5, 91], [0, 0]] - latitude too high
+ ];
+ for invalidString in invalidStrings {
+ XCTAssertThrowsError(try Polyline.decodeToLngLatArray(invalidString)) { error in
+ XCTAssertEqual(error as! Polyline.DecodeError, Polyline.DecodeError.invalidCoordinateValue);
+ };
+ }
+ }
+
+
+ func testPolyline6DecodeInvalidValuesThrowsError() {
+ Polyline.setCompressionAlgorithm(.Polyline6);
+ let invalidStrings = [
+ "_sdpH~rjfxI~rdpH_sjfxI", // [[-181, 5], [0, 0]] - longitude too low
+ "_sdpH_sjfxI~rdpH~rjfxI", // [[181, 5], [0, 0]] - longitude too high
+ "~jeqlD_sdpH_keqlD~rdpH", // [[5, -91], [0, 0]] - latitude too low
+ "_keqlD_sdpH~jeqlD~rdpH", // [[5, 91], [0, 0]] - latitude too high
+ ];
+ for invalidString in invalidStrings {
+ XCTAssertThrowsError(try Polyline.decodeToLngLatArray(invalidString)) { error in
+ XCTAssertEqual(error as! Polyline.DecodeError, Polyline.DecodeError.invalidCoordinateValue);
+ };
+ }
+ }
+
+ // FlexiblePolyline is the only format that supports 3D data, so specifically test that algorithm to ensure
+ // that the 3D data works as expected.
+
+ func testFlexiblePolylineLngLatArrayHandlesThirdDimensionTypes() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let coords = [
+ [0.0, 0.0, 5.0],
+ [10.0, 0.0, 0.0],
+ [10.0, 10.0, -5.0],
+ [0.0, 10.0, 0.0],
+ [0.0, 0.0, 5.0],
+ ];
+ for thirdDimension in [Polyline.ThirdDimension.Level, Polyline.ThirdDimension.Altitude, Polyline.ThirdDimension.Elevation] {
+ do {
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords, parameters: CompressionParameters(
+ thirdDimension: thirdDimension
+ ));
+ let result = try Polyline.decodeToLngLatArray(encodedLine);
+ XCTAssertEqual(result, coords);
+ } catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+ func testFlexiblePolylineLineStringHandlesThirdDimensionTypes() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let coords = [
+ [0.0, 0.0, 5.0],
+ [10.0, 0.0, 0.0],
+ [10.0, 10.0, -5.0],
+ [0.0, 10.0, 0.0],
+ [0.0, 0.0, 5.0],
+ ];
+ for thirdDimension in [Polyline.ThirdDimension.Level, Polyline.ThirdDimension.Altitude, Polyline.ThirdDimension.Elevation] {
+ do {
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords, parameters: CompressionParameters(
+ thirdDimension: thirdDimension
+ ));
+ let geojson = try Polyline.decodeToLineString(encodedLine);
+
+ validateLineString(geojson:geojson, coords:coords);
+ } catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+ func testFlexiblePolylineLineStringFeatureHandlesThirdDimensionTypes() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let coords = [
+ [0.0, 0.0, 5.0],
+ [10.0, 0.0, 0.0],
+ [10.0, 10.0, -5.0],
+ [0.0, 10.0, 0.0],
+ [0.0, 0.0, 5.0],
+ ];
+ for thirdDimension in [Polyline.ThirdDimension.Level, Polyline.ThirdDimension.Altitude, Polyline.ThirdDimension.Elevation] {
+ do {
+ let parameters = CompressionParameters(
+ thirdDimension: thirdDimension
+ );
+ let encodedLine = try Polyline.encodeFromLngLatArray(lngLatArray: coords, parameters: parameters);
+ let geojson = try Polyline.decodeToLineStringFeature(encodedLine);
+ validateLineStringFeature(geojson:geojson, coords:coords, parameters:parameters);
+ } catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+ func testFlexiblePolylinePolygonHandlesThirdDimensionTypes() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let ringCoords = [
+ [
+ [0.0, 0.0, 5.0],
+ [10.0, 0.0, 0.0],
+ [10.0, 10.0, -5.0],
+ [0.0, 10.0, 0.0],
+ [0.0, 0.0, 5.0],
+ ], // outer ring
+ [
+ [2.0, 2.0, 5.0],
+ [2.0, 8.0, 0.0],
+ [8.0, 8.0, -5.0],
+ [8.0, 2.0, 0.0],
+ [2.0, 2.0, 5.0],
+ ], // inner ring
+ ];
+ for thirdDimension in [Polyline.ThirdDimension.Level, Polyline.ThirdDimension.Altitude, Polyline.ThirdDimension.Elevation] {
+ do {
+ var encodedRings:Array = [];
+ for ring in ringCoords {
+ encodedRings.append(
+ try Polyline.encodeFromLngLatArray(lngLatArray: ring, parameters:
+ CompressionParameters(thirdDimension: thirdDimension)
+ ));
+ }
+ let geojson = try Polyline.decodeToPolygon(encodedRings);
+ validatePolygon(geojson:geojson, coords:ringCoords);
+ } catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+ func testFlexiblePolylinePolygonFeatureHandlesThirdDimensionTypes() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+ let ringCoords = [
+ [
+ [0.0, 0.0, 5.0],
+ [10.0, 0.0, 0.0],
+ [10.0, 10.0, -5.0],
+ [0.0, 10.0, 0.0],
+ [0.0, 0.0, 5.0],
+ ], // outer ring
+ [
+ [2.0, 2.0, 5.0],
+ [2.0, 8.0, 0.0],
+ [8.0, 8.0, -5.0],
+ [8.0, 2.0, 0.0],
+ [2.0, 2.0, 5.0],
+ ], // inner ring
+ ];
+ for thirdDimension in [Polyline.ThirdDimension.Level, Polyline.ThirdDimension.Altitude, Polyline.ThirdDimension.Elevation] {
+ do {
+ let parameters = CompressionParameters(thirdDimension: thirdDimension);
+ var encodedRings:Array = [];
+ for ring in ringCoords {
+ encodedRings.append(
+ try Polyline.encodeFromLngLatArray(lngLatArray: ring, parameters:
+ parameters
+ ));
+ }
+ let geojson = try Polyline.decodeToPolygonFeature(encodedRings);
+ validatePolygonFeature(geojson:geojson, coords:ringCoords, parameters:parameters);
+ } catch {
+ XCTFail("Unexpected error");
+ }
+ }
+ }
+ func testPolylineErrorsOnThreeDimensions() {
+ let coords = [
+ [0.0, 0.0, 5.0],
+ [10.0, 0.0, 0.0],
+ [10.0, 10.0, -5.0],
+ [0.0, 10.0, 0.0],
+ [0.0, 0.0, 5.0],
+ ];
+ for algorithm in [Polyline.CompressionAlgorithm.Polyline5, Polyline.CompressionAlgorithm.Polyline6] {
+ Polyline.setCompressionAlgorithm(algorithm);
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: coords, parameters: CompressionParameters(
+ thirdDimension: ThirdDimension.Altitude
+ ))) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.inconsistentCoordinateDimensions);
+ };
+ }
+ }
+
+ // Verify that FlexiblePolyline checks for valid encoding settings
+
+ func testFlexiblePolylineEncodeThrowsErrorWithNegative2DPrecision() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+
+ let coords = [[0.0, 0.0, 5.0], [10.0, 0.0, 0.0]];
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: coords, parameters: CompressionParameters(precisionLngLat: -5))) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.invalidPrecisionValue);
+ };
+ }
+ func testFlexiblePolylineEncodeThrowsErrorWithNegative3DPrecision() {
+ Polyline.setCompressionAlgorithm(.FlexiblePolyline);
+
+ let coords = [[0.0, 0.0, 5.0], [10.0, 0.0, 0.0]];
+ XCTAssertThrowsError(try Polyline.encodeFromLngLatArray(lngLatArray: coords, parameters: CompressionParameters(precisionThirdDimension: -5))) { error in
+ XCTAssertEqual(error as! Polyline.EncodeError, Polyline.EncodeError.invalidPrecisionValue);
+ };
+ }}
+