├── .spi.yml ├── .swiftlint.yml ├── Makefile ├── .swiftformat ├── Tests └── GPXKitTests │ ├── GeoCoordinateExtensionsTests.swift │ ├── MapKitExtensionsTests.swift │ ├── TrackGraphCoreLocationTests.swift │ ├── CombineExtensionTests.swift │ ├── GradeSegmentTests.swift │ ├── GeoBoundsTests.swift │ ├── GPXTrackTests.swift │ ├── CollectionExtensionsTests.swift │ ├── TestHelpers.swift │ ├── GPXExporterTests.swift │ ├── TrackGraphTests.swift │ └── GPXParserTests.swift ├── Sources └── GPXKit │ ├── CoreLocationSupport.swift │ ├── ISO8601DateFormatter.swift │ ├── Coordinate.swift │ ├── TrackSegment.swift │ ├── Waypoint.swift │ ├── DistanceHeight.swift │ ├── CombineSupport.swift │ ├── GeoBounds.swift │ ├── BasicXMLParser.swift │ ├── GradeSegment.swift │ ├── Climb.swift │ ├── TrackPoint.swift │ ├── GPXTrack.swift │ ├── TrackGraph+Private.swift │ ├── GPXExporter.swift │ ├── GeoCoordinate.swift │ ├── CollectionExtensions.swift │ ├── DistanceCalculation.swift │ ├── TrackGraph.swift │ └── GPXFileParser.swift ├── LICENSE ├── Package@swift-6.0.swift ├── Package.swift ├── Package.resolved ├── .gitignore └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [GPXKit] -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | line_length: 2 | warning: 140 3 | ignores_function_declarations: true 4 | ignores_comments: true 5 | ignores_interpolated_strings: true 6 | ignores_urls: true 7 | 8 | disabled_rules: 9 | - identifier_name 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | clean: 4 | swift package clean 5 | 6 | force-clean: 7 | rm -rf .build 8 | 9 | build: 10 | swift build 11 | 12 | build-release: 13 | swift build -c release --disable-sandbox 14 | 15 | test: 16 | swift test --enable-swift-testing 17 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --maxwidth 140 2 | --wraparguments before-first 3 | --wrapcollections before-first 4 | #--exclude "**/Package.swift" 5 | --indent 4 6 | --header "\nGPXKit - MIT License - Copyright © {year} Markus Müller. All rights reserved.\n" 7 | --binarygrouping none 8 | --commas inline 9 | --decimalgrouping none 10 | --hexgrouping none 11 | --hexliteralcase lowercase 12 | --ifdef no-indent 13 | --octalgrouping none 14 | --semicolons never 15 | --stripunusedargs closure-only 16 | --disable unusedArguments 17 | --disable preferKeyPath 18 | --disable hoistTry 19 | --funcattributes prev-line 20 | --typeattributes prev-line 21 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/GeoCoordinateExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import GPXKit 7 | import Numerics 8 | import Testing 9 | 10 | struct GeoCoordinateExtensionsTests { 11 | @Test(arguments: 1 ... 180) 12 | func testRadiusForDelta(value: Int) { 13 | let degree = Double(value) 14 | let location = Coordinate(latitude: 51.323331, longitude: 12.368279, elevation: 110) 15 | 16 | let expected = degree * 111.045 / 2.0 * 1000 17 | // one degree of latitude is always approximately 111 kilometers (69 miles) 18 | #expect( 19 | expected.isApproximatelyEqual(to: location.radiusInMeters(latitudeDelta: degree), absoluteTolerance: 139 * degree), 20 | "Radius \(location.radiusInMeters(latitudeDelta: degree)) for \(degree) not in expected range \(expected)" 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/GPXKit/CoreLocationSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | #if canImport(CoreLocation) 7 | 8 | import CoreLocation 9 | 10 | public extension TrackGraph { 11 | /// Array of `CLLocationCoordinate2D` values from the `TrackGraph`s segments. 12 | var coreLocationCoordinates: [CLLocationCoordinate2D] { 13 | return segments.map { 14 | CLLocationCoordinate2D(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) 15 | } 16 | } 17 | } 18 | 19 | public extension CLLocationCoordinate2D { 20 | /// Convenience initializer for creation of a `CLLocationCoordinate2D` from a `GeoCoordinate` 21 | /// - Parameter coord: A type which conforms to the `GeoCoordinate` protocol. 22 | init(_ coord: any GeoCoordinate) { 23 | self.init(latitude: coord.latitude, longitude: coord.longitude) 24 | } 25 | } 26 | 27 | #endif 28 | -------------------------------------------------------------------------------- /Sources/GPXKit/ISO8601DateFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension ISO8601DateFormatter { 8 | nonisolated(unsafe) static var importing: ISO8601DateFormatter = { 9 | let formatter = ISO8601DateFormatter() 10 | formatter.formatOptions = .withInternetDateTime 11 | return formatter 12 | }() 13 | 14 | nonisolated(unsafe) static var importingFractionalSeconds: ISO8601DateFormatter = { 15 | let formatter = ISO8601DateFormatter() 16 | if #available(macOS 10.13, iOS 12, tvOS 11.0, *) { 17 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 18 | } else { 19 | formatter.formatOptions = .withInternetDateTime 20 | } 21 | return formatter 22 | }() 23 | 24 | nonisolated(unsafe) static var exporting: ISO8601DateFormatter = { 25 | let formatter = ISO8601DateFormatter() 26 | formatter.formatOptions = .withInternetDateTime 27 | return formatter 28 | }() 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Markus Müller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/MapKitExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import GPXKit 7 | import Testing 8 | 9 | #if canImport(MapKit) && canImport(CoreLocation) && !os(watchOS) 10 | import CoreLocation 11 | import MapKit 12 | 13 | @Suite 14 | struct CoordinateExtensionTests { 15 | @Test 16 | func testPolyLine() { 17 | let coordinates: [Coordinate] = [ 18 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 82.2), 19 | Coordinate(latitude: 53.2763700, longitude: 11.3767550, elevation: 82.2), 20 | Coordinate(latitude: 54.2764100, longitude: 10.3767400, elevation: 82.2), 21 | Coordinate(latitude: 55.2764520, longitude: 9.3767260, elevation: 82.2), 22 | Coordinate(latitude: 57.2765020, longitude: 8.3767050, elevation: 82.2) 23 | ] 24 | 25 | let polyline = coordinates.polyLine 26 | let points = Array(UnsafeBufferPointer(start: polyline.points(), count: polyline.pointCount)) 27 | let expected = coordinates.map(CLLocationCoordinate2D.init) 28 | assertGeoCoordinatesEqual(expected, points.map { $0.coordinate }, accuracy: 0.0001) 29 | } 30 | } 31 | 32 | extension CLLocationCoordinate2D: GeoCoordinate {} 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/GPXKit/Coordinate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Basic type for storing a geo location. 8 | public struct Coordinate: GeoCoordinate, HeightMappable, Hashable, Sendable { 9 | /// Latitude value in degrees 10 | public var latitude: Double 11 | /// Longitude value in degrees 12 | public var longitude: Double 13 | /// Elevation in meters 14 | public var elevation: Double = 0 15 | 16 | /// Initializer 17 | /// - Parameters: 18 | /// - latitude: Latitude in degrees 19 | /// - longitude: Longitude in degrees 20 | /// - elevation: Elevation in meters, defaults to zero. 21 | public init(latitude: Double, longitude: Double, elevation: Double = .zero) { 22 | self.latitude = latitude 23 | self.longitude = longitude 24 | self.elevation = elevation 25 | } 26 | 27 | public static func == (lhs: Coordinate, rhs: Coordinate) -> Bool { 28 | (lhs.latitude - rhs.latitude).magnitude < 0.000001 && 29 | (lhs.longitude - rhs.longitude).magnitude < 0.000001 && 30 | (lhs.elevation - rhs.elevation).magnitude < 0.00001 31 | } 32 | } 33 | 34 | extension Coordinate: DistanceCalculation { 35 | func calculateDistance(to other: Coordinate) -> Double { 36 | distance(to: other) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/TrackGraphCoreLocationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import GPXKit 7 | import Testing 8 | #if canImport(CoreLocation) 9 | import CoreLocation 10 | 11 | extension TrackGraphTests { 12 | // MARK: Tests 13 | 14 | @available(macOS 10.12, iOS 8, *) 15 | @Test 16 | func testCLCoordinates2D() throws { 17 | let expected: [CLLocationCoordinate2D] = [ 18 | CLLocationCoordinate2D(latitude: 51.2763320, longitude: 12.3767670), 19 | CLLocationCoordinate2D(latitude: 51.2763700, longitude: 12.3767550), 20 | CLLocationCoordinate2D(latitude: 51.2764100, longitude: 12.3767400), 21 | CLLocationCoordinate2D(latitude: 51.2764520, longitude: 12.3767260), 22 | CLLocationCoordinate2D(latitude: 51.2765020, longitude: 12.3767050) 23 | ] 24 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 25 | expectNoDifference(expected, sut.coreLocationCoordinates) 26 | } 27 | } 28 | 29 | extension CLLocationCoordinate2D: Equatable { 30 | public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { 31 | if lhs.latitude != rhs.latitude { 32 | return false 33 | } 34 | return lhs.longitude == rhs.longitude 35 | } 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /Sources/GPXKit/TrackSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Value type describing a logical segment in a `TrackGraph`. A `TrackGraph` consists of a collection of `TrackSegment`s. Each has a 8 | /// coordinate (latitude, longitude & elevation) and the distance (in meters) to its preceding segment point. 9 | public struct TrackSegment: Hashable, Sendable { 10 | /// The ``Coordinate`` (latitude, longitude and elevation) 11 | public var coordinate: Coordinate 12 | 13 | /// Distance in meters to its preceding `TrackSegment` in a `TrackGraph` 14 | public var distanceInMeters: Double 15 | 16 | /// Initializes a `TrackSegment` 17 | /// You don't need to construct this value by yourself, as it is done by GXPKits track parsing logic. 18 | /// - Parameters: 19 | /// - coordinate: A ``Coordinate`` struct, contains latitude/longitude and elevation 20 | /// - distanceInMeters: Distance in meters to its preceding `TrackSegment` in a `TrackGraph` 21 | public init(coordinate: Coordinate, distanceInMeters: Double) { 22 | self.coordinate = coordinate 23 | self.distanceInMeters = distanceInMeters 24 | } 25 | } 26 | 27 | extension Collection where Element == TrackSegment { 28 | func calculateDistance() -> Double { 29 | reduce(0) { $0 + $1.distanceInMeters } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/GPXKit/Waypoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Value type describing a single Waypoint defined within a `GPXTrack`. A `Waypoint` has a location consisting of latitude, longitude and 8 | /// some metadata, 9 | /// e.g. name and description. 10 | public struct Waypoint: Hashable, Sendable { 11 | /// The ``Coordinate`` (latitude, longitude and elevation in meters) 12 | public var coordinate: Coordinate 13 | /// Optional date for a given point. 14 | public var date: Date? 15 | /// Optional name of the waypoint 16 | public var name: String? 17 | /// Optional comment for the waypoint 18 | public var comment: String? 19 | /// Optional description of the waypoint 20 | public var description: String? 21 | 22 | /// Initializer 23 | /// You don't need to construct this value by yourself, as it is done by GXPKits track parsing logic. 24 | /// - Parameters: 25 | /// - coordinate: ``Coordinate`` of the waypoint, required 26 | /// - date: Optional date 27 | /// - name: Name of the waypoint 28 | /// - comment: A short comment 29 | /// - description: A longer description 30 | public init(coordinate: Coordinate, date: Date? = nil, name: String? = nil, comment: String? = nil, description: String? = nil) { 31 | self.coordinate = coordinate 32 | self.date = date 33 | self.name = name 34 | self.comment = comment 35 | self.description = description 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "GPXKit", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_13), 10 | .watchOS(.v6), 11 | .tvOS(.v12) 12 | ], 13 | products: [ 14 | .library( 15 | name: "GPXKit", 16 | targets: ["GPXKit"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), 21 | .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"), 22 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.3"), 23 | .package(url: "https://github.com/apple/swift-numerics", from: "1.0.2"), 24 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.1") 25 | ], 26 | targets: [ 27 | .target( 28 | name: "GPXKit", 29 | dependencies: [ 30 | .product(name: "Algorithms", package: "swift-algorithms") 31 | ] 32 | ), 33 | .testTarget( 34 | name: "GPXKitTests", 35 | dependencies: [ 36 | "GPXKit", 37 | .product(name: "CustomDump", package: "swift-custom-dump"), 38 | .product(name: "Numerics", package: "swift-numerics") 39 | ] 40 | ) 41 | ], 42 | swiftLanguageModes: [.v6] 43 | ) 44 | 45 | for target in package.targets { 46 | target.swiftSettings = target.swiftSettings ?? [] 47 | target.swiftSettings?.append(contentsOf: [ 48 | .enableUpcomingFeature("ExistentialAny") 49 | ]) 50 | } 51 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let settings: [SwiftSetting] = [ 6 | .enableExperimentalFeature("StrictConcurrency") 7 | ] 8 | 9 | let package = Package( 10 | name: "GPXKit", 11 | platforms: [ 12 | .iOS(.v13), 13 | .macOS(.v10_13), 14 | .watchOS(.v6), 15 | .tvOS(.v12) 16 | ], 17 | products: [ 18 | .library( 19 | name: "GPXKit", 20 | targets: ["GPXKit"] 21 | ) 22 | ], 23 | dependencies: [ 24 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), 25 | .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"), 26 | .package(url: "https://github.com/apple/swift-numerics", from: "1.0.2"), 27 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.2") 28 | ], 29 | targets: [ 30 | .target( 31 | name: "GPXKit", 32 | dependencies: [ 33 | .product(name: "Algorithms", package: "swift-algorithms") 34 | ], 35 | swiftSettings: settings 36 | ), 37 | .testTarget( 38 | name: "GPXKitTests", 39 | dependencies: [ 40 | "GPXKit", 41 | .product(name: "CustomDump", package: "swift-custom-dump"), 42 | .product(name: "Numerics", package: "swift-numerics") 43 | ], 44 | swiftSettings: settings 45 | ) 46 | ] 47 | ) 48 | 49 | #if swift(>=5.6) 50 | // Add the documentation compiler plugin if possible 51 | package.dependencies.append( 52 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.3") 53 | ) 54 | #endif 55 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/CombineExtensionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import GPXKit 7 | import Testing 8 | #if canImport(Combine) 9 | import Combine 10 | 11 | @Suite 12 | struct CombineExtensionTests { 13 | @Test 14 | @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) 15 | func testLoadFromPublisher() async throws { 16 | await confirmation("publisher") { conf in 17 | let sut = GPXFileParser(xmlString: testXMLWithoutExtensions) 18 | 19 | let cancellable = sut.publisher 20 | .sink { completion in 21 | switch completion { 22 | case .finished: 23 | break 24 | case let .failure(error): 25 | Issue.record(error) 26 | } 27 | conf() 28 | } receiveValue: { track in 29 | assertTracksAreEqual(testTrack, track) 30 | } 31 | } 32 | } 33 | 34 | @Test 35 | @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) 36 | func testLoadFromDataFactoryMethod() async throws { 37 | await confirmation("data factory method") { conf in 38 | let cancellable = GPXFileParser.load(from: testXMLWithoutExtensions.data(using: .utf8)!) 39 | .sink { completion in 40 | switch completion { 41 | case .finished: 42 | break 43 | case let .failure(error): 44 | Issue.record(error) 45 | } 46 | conf() 47 | } receiveValue: { track in 48 | assertTracksAreEqual(testTrack, track) 49 | } 50 | } 51 | } 52 | } 53 | 54 | #endif 55 | -------------------------------------------------------------------------------- /Sources/GPXKit/DistanceHeight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// A value describing an entry in a ``TrackGraph``s height-map. It has the total distance in meters up to that point in the track along 8 | /// with the elevation in meters above sea level at that given point in a track (imagine the distance as the value along the x-axis in a 9 | /// 2D-coordinate graph, the elevation as the y-value). 10 | public struct DistanceHeight: Hashable, Sendable { 11 | /// Total distance from the tracks start location in meters 12 | public var distance: Double 13 | /// Elevation in meters above sea level at that position in the track 14 | public var elevation: Double 15 | 16 | /// Initializes a ``DistanceHeight`` value. You don't need to construct this value by yourself, as it is done by GPXKits track parsing 17 | /// logic. 18 | /// - Parameters: 19 | /// - distance: Distance from the tracks start location in meters. 20 | /// - elevation: Elevation in meters above sea level at that track position. 21 | public init(distance: Double, elevation: Double) { 22 | self.distance = distance 23 | self.elevation = elevation 24 | } 25 | } 26 | 27 | import Algorithms 28 | 29 | public extension [DistanceHeight] { 30 | func gradeSegments() -> [GradeSegment] { 31 | adjacentPairs().reduce(into: [GradeSegment]()) { acc, value in 32 | let (prev, cur) = value 33 | let length = (cur.distance - prev.distance) 34 | let start = acc.last?.end ?? .zero 35 | guard length > 0 else { return } 36 | if let segment = GradeSegment( 37 | start: start, 38 | end: start + length, 39 | elevationAtStart: prev.elevation, 40 | elevationAtEnd: cur.elevation 41 | ) { 42 | acc.append(segment) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ae629771740d4937bf23d166dc8bb4ad067ef63a6b613b59fed781f3dc9b140b", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-algorithms", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-algorithms", 8 | "state" : { 9 | "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", 10 | "version" : "1.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-custom-dump", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 17 | "state" : { 18 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 19 | "version" : "1.3.3" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-docc-plugin", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-docc-plugin", 26 | "state" : { 27 | "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", 28 | "version" : "1.4.3" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-docc-symbolkit", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 35 | "state" : { 36 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 37 | "version" : "1.0.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-numerics", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-numerics.git", 44 | "state" : { 45 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 46 | "version" : "1.0.2" 47 | } 48 | }, 49 | { 50 | "identity" : "xctest-dynamic-overlay", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 53 | "state" : { 54 | "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938", 55 | "version" : "1.5.1" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/GradeSegmentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import GPXKit 7 | import Numerics 8 | import Testing 9 | 10 | struct GradeSegmentTests { 11 | @Test 12 | func testValidGrades() throws { 13 | try stride(from: -0.3, through: 0.3, by: 0.01).forEach { grade in 14 | let start = Double.random(in: 0 ... 1000) 15 | let segment = try #require(GradeSegment(start: start, end: start + 10, grade: grade)) 16 | #expect(grade.isApproximatelyEqual(to: segment.grade, absoluteTolerance: 0.01)) 17 | 18 | _ = try #require(GradeSegment(start: start, end: start + 10, elevationAtStart: start, elevationAtEnd: start + grade * 0.99)) 19 | _ = try #require(GradeSegment(start: start, end: start + 10, elevationAtStart: start, elevationAtEnd: start - grade * 0.99)) 20 | } 21 | } 22 | 23 | @Test 24 | func testItIsNotPossibleToCreateSegmentsWithGradesGreaterThanThirtyPercent() throws { 25 | let start = Double.random(in: 0 ... 1000) 26 | #expect(GradeSegment(start: start, end: start + 10, grade: 0.31) == nil) 27 | #expect(GradeSegment(start: start, end: start + 10, grade: -0.31) == nil) 28 | 29 | #expect(GradeSegment( 30 | start: start, 31 | end: start + 100, 32 | elevationAtStart: start, 33 | elevationAtEnd: start + Double.random(in: 31 ... 100) 34 | ) == nil) 35 | #expect(GradeSegment( 36 | start: start, 37 | end: start + 100, 38 | elevationAtStart: start, 39 | elevationAtEnd: start - Double.random(in: 31 ... 100) 40 | ) == nil) 41 | } 42 | 43 | @Test 44 | func testItIsNotPossibleToCreateSegmentsWithGainGraterThanLength() throws { 45 | let length = Double.random(in: 10 ... 100) 46 | let gain = Double.random(in: 10 ... 100) 47 | 48 | #expect(GradeSegment(start: 0, end: min(length, gain), elevationAtStart: 0, elevationAtEnd: max(length, gain)) == nil) 49 | #expect(GradeSegment(start: 0, end: min(length, gain), elevationAtStart: 0, elevationAtEnd: -max(length, gain)) == nil) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/GPXKit/CombineSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | #if canImport(Combine) 7 | import Combine 8 | 9 | @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) 10 | public extension GPXFileParser { 11 | /// Publisher for bridging into Combine. 12 | /// 13 | /// An AnyPublisher with Output `GPXTrack` and Failure of `GPXParserError`. 14 | var publisher: AnyPublisher { 15 | Future { promise in 16 | promise(self.parse()) 17 | }.eraseToAnyPublisher() 18 | } 19 | 20 | /// Helper for loading a gpx track from an url. 21 | /// - Parameter url: The url of the GPX file. See [GPX specification for details](https://www.topografix.com/gpx.asp). 22 | /// - Returns: An AnyPublisher with Output `GPXTrack` and Failure of `GPXParserError`. 23 | /// 24 | /// ```swift 25 | /// let url = // ... url with GPX file 26 | /// GPXFileParser 27 | /// .load(from: url) 28 | /// .map { track in 29 | /// // do something with track 30 | /// } 31 | /// .catch { 32 | /// /// handle parsing error 33 | /// } 34 | /// ``` 35 | static func load(from url: URL) -> AnyPublisher { 36 | guard let parser = GPXFileParser(url: url) else { return Fail(error: GPXParserError.invalidGPX).eraseToAnyPublisher() } 37 | return parser.publisher 38 | } 39 | 40 | /// Helper for loading a gpx track from data. 41 | /// - Parameter data: The data containing the GPX as xml. See [GPX specification for details](https://www.topografix.com/gpx.asp). 42 | /// - Returns: An AnyPublisher with Output `GPXTrack` and Failure of `GPXParserError`. 43 | /// 44 | /// ```swift 45 | /// let data = xmlString.data(using: .utf8) 46 | /// GPXFileParser 47 | /// .load(from: data) 48 | /// .map { track in 49 | /// // do something with track 50 | /// } 51 | /// .catch { 52 | /// /// handle parsing error 53 | /// } 54 | /// ``` 55 | static func load(from data: Data) -> AnyPublisher { 56 | guard let parser = GPXFileParser(data: data) else { return Fail(error: GPXParserError.invalidGPX).eraseToAnyPublisher() } 57 | return parser.publisher 58 | } 59 | } 60 | 61 | #endif 62 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/GeoBoundsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import GPXKit 7 | import Testing 8 | 9 | @Suite 10 | struct GeoBoundsTests { 11 | func boundsWith(leftEdge: Double, topEdge: Double, size: Double) -> GeoBounds { 12 | let latRange = (leftEdge ... (leftEdge + size)).clamped(to: Coordinate.validLatitudeRange) 13 | let lonRange = (topEdge ... (topEdge + size)).clamped(to: Coordinate.validLongitudeRange) 14 | return GeoBounds( 15 | minLatitude: latRange.lowerBound, 16 | minLongitude: lonRange.lowerBound, 17 | maxLatitude: latRange.upperBound, 18 | maxLongitude: lonRange.upperBound 19 | ) 20 | } 21 | 22 | @Test 23 | func testIntersectionOfNonIntersectingBounds() { 24 | // lat: 20...40, lon: 20...40 25 | let sut = boundsWith(leftEdge: 20, topEdge: 20, size: 20) 26 | 27 | // at: 41...60, lon: 20...40 rhs is completely on sut's right edge 28 | #expect(sut.intersects(boundsWith(leftEdge: 41, topEdge: 20, size: 20)) == false) 29 | 30 | // at: 5...15, lon: 20...30 rhs is completely on sut's left edge 31 | #expect(sut.intersects(boundsWith(leftEdge: 5, topEdge: 20, size: 10)) == false) 32 | 33 | // at: 20...30, lon: -20...-10 rhs is completely above sut's top edge 34 | #expect(sut.intersects(boundsWith(leftEdge: 20, topEdge: -20, size: 10)) == false) 35 | 36 | // at: 5...15, lon: 50...60 rhs is completely below sut's bottom edge 37 | #expect(sut.intersects(boundsWith(leftEdge: 5, topEdge: 50, size: 10)) == false) 38 | } 39 | 40 | @Test 41 | func testIntersectionOfOverlappingBounds() { 42 | // lat: 0...100, lon: 0...100 43 | let sut = boundsWith(leftEdge: 0, topEdge: 0, size: 100) 44 | 45 | // overlaps lat (41...141) & lon (20...120) 46 | #expect(sut.intersects(boundsWith(leftEdge: 41, topEdge: 20, size: 100))) 47 | // overlaps lat (-20...22) & lon (20...62) 48 | #expect(sut.intersects(boundsWith(leftEdge: -20, topEdge: 20, size: 42))) 49 | // overlaps lat (-40...20) & lon (-30...30) 50 | #expect(sut.intersects(boundsWith(leftEdge: 20, topEdge: -30, size: 60))) 51 | } 52 | 53 | @Test 54 | func testBoundsFromRadius() throws { 55 | let bounds = try #require(Coordinate.leipzig.bounds(distanceInMeters: 10000)) 56 | 57 | #expect(Coordinate.leipzig.distance(to: Coordinate(latitude: bounds.minLatitude, longitude: bounds.minLongitude)) > 8000) 58 | #expect(Coordinate.leipzig.distance(to: Coordinate(latitude: bounds.maxLatitude, longitude: bounds.maxLatitude)) > 8000) 59 | 60 | #expect(bounds.contains(Coordinate.leipzig)) 61 | #expect(bounds.contains(Coordinate.dehner)) 62 | #expect(bounds.contains(Coordinate.kreisel)) 63 | #expect(bounds.contains(Coordinate.postPlatz) == false) 64 | try #expect(#require(Coordinate.leipzig.bounds(distanceInMeters: 100000)).contains(Coordinate.postPlatz)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/swiftpackagemanager,swiftpm,swift 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=swiftpackagemanager,swiftpm,swift 10 | 11 | ### Swift ### 12 | # Xcode 13 | # 14 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 15 | 16 | ## User settings 17 | xcuserdata/ 18 | 19 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 20 | *.xcscmblueprint 21 | *.xccheckout 22 | 23 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 24 | build/ 25 | DerivedData/ 26 | *.moved-aside 27 | *.pbxuser 28 | !default.pbxuser 29 | *.mode1v3 30 | !default.mode1v3 31 | *.mode2v3 32 | !default.mode2v3 33 | *.perspectivev3 34 | !default.perspectivev3 35 | 36 | ## Obj-C/Swift specific 37 | *.hmap 38 | 39 | ## App packaging 40 | *.ipa 41 | *.dSYM.zip 42 | *.dSYM 43 | 44 | ## Playgrounds 45 | timeline.xctimeline 46 | playground.xcworkspace 47 | 48 | # Swift Package Manager 49 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 50 | # Packages/ 51 | # Package.pins 52 | # Package.resolved 53 | # *.xcodeproj 54 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 55 | # hence it is not needed unless you have added a package configuration file to your project 56 | # .swiftpm 57 | 58 | .build/ 59 | 60 | # CocoaPods 61 | # We recommend against adding the Pods directory to your .gitignore. However 62 | # you should judge for yourself, the pros and cons are mentioned at: 63 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 64 | # Pods/ 65 | # Add this line if you want to avoid checking in source code from the Xcode workspace 66 | # *.xcworkspace 67 | 68 | # Carthage 69 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 70 | # Carthage/Checkouts 71 | 72 | Carthage/Build/ 73 | 74 | # Accio dependency management 75 | Dependencies/ 76 | .accio/ 77 | 78 | # fastlane 79 | # It is recommended to not store the screenshots in the git repo. 80 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 81 | # For more information about the recommended setup visit: 82 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 83 | 84 | fastlane/report.xml 85 | fastlane/Preview.html 86 | fastlane/screenshots/**/*.png 87 | fastlane/test_output 88 | 89 | # Code Injection 90 | # After new code Injection tools there's a generated folder /iOSInjectionProject 91 | # https://github.com/johnno1962/injectionforxcode 92 | 93 | iOSInjectionProject/ 94 | 95 | ### SwiftPackageManager ### 96 | Packages 97 | xcuserdata 98 | *.xcodeproj 99 | 100 | 101 | ### SwiftPM ### 102 | 103 | 104 | # End of https://www.toptal.com/developers/gitignore/api/swiftpackagemanager,swiftpm,swift 105 | 106 | .bundle 107 | /.idea 108 | junit-swift-testing.xml 109 | -------------------------------------------------------------------------------- /Sources/GPXKit/GeoBounds.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// A 2D-bounding box describing the area enclosing a track. 8 | public struct GeoBounds: Hashable, Codable, Sendable { 9 | /// The minimum latitude value in degrees 10 | public var minLatitude: Double 11 | /// The minimum longitude value in degrees 12 | public var minLongitude: Double 13 | /// The maximum latitude value in degrees 14 | public var maxLatitude: Double 15 | /// The maximum longitude value in degrees 16 | public var maxLongitude: Double 17 | 18 | /// Initialized a ``GeoBounds`` value. You don't need to construct this value by yourself, as it is done by GXPKits track parsing logic. 19 | /// - Parameters: 20 | /// - minLatitude: The minimum latitude value in degrees. 21 | /// - minLongitude: The minimum longitude value in degrees. 22 | /// - maxLatitude: The maximum latitude value in degrees. 23 | /// - maxLongitude: The maximum longitude value in degrees. 24 | public init(minLatitude: Double, minLongitude: Double, maxLatitude: Double, maxLongitude: Double) { 25 | self.minLatitude = minLatitude 26 | self.minLongitude = minLongitude 27 | self.maxLatitude = maxLatitude 28 | self.maxLongitude = maxLongitude 29 | } 30 | } 31 | 32 | public extension GeoBounds { 33 | /// The _zero_ value of GeoBounds. 34 | /// 35 | /// Its values are not zero but contain the following values: 36 | /// ### minLatitude 37 | /// `Coordinate.validLatitudeRange.upperBound` 38 | /// ### minLongitude 39 | /// `Coordinate.validLongitudeRange.upperBound` 40 | /// ### maxLatitude 41 | /// `Coordinate.validLatitudeRange.lowerBound` 42 | /// #### maxLongitude 43 | /// `Coordinate.validLongitudeRange.lowerBound` 44 | /// 45 | /// See `Coordinate.validLongitudeRange` & `Coordinate.validLatitudeRange.upperBound` for details. 46 | static let empty = GeoBounds( 47 | minLatitude: Coordinate.validLatitudeRange.upperBound, 48 | minLongitude: Coordinate.validLongitudeRange.upperBound, 49 | maxLatitude: Coordinate.validLatitudeRange.lowerBound, 50 | maxLongitude: Coordinate.validLongitudeRange.lowerBound 51 | ) 52 | 53 | /// Tests if two `GeoBound` values intersects 54 | /// - Parameter rhs: The other `GeoBound` to test for intersection. 55 | /// - Returns: True if both bounds intersect, otherwise false. 56 | func intersects(_ rhs: GeoBounds) -> Bool { 57 | return (minLatitude ... maxLatitude).overlaps(rhs.minLatitude ... rhs.maxLatitude) && 58 | (minLongitude ... maxLongitude).overlaps(rhs.minLongitude ... rhs.maxLongitude) 59 | } 60 | 61 | /// Tests if a `GeoCoordinate` is within a `GeoBound` 62 | /// - Parameter coordinate: The `GeoCoordinate` to test for. 63 | /// - Returns: True if coordinate is within the bounds otherwise false. 64 | func contains(_ coordinate: any GeoCoordinate) -> Bool { 65 | return (minLatitude ... maxLatitude).contains(coordinate.latitude) && 66 | (minLongitude ... maxLongitude).contains(coordinate.longitude) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/GPXKit/BasicXMLParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | #if canImport(FoundationXML) 7 | import FoundationXML 8 | #endif 9 | 10 | extension String { 11 | static let trackPointExtensionURL: Self = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1" 12 | } 13 | 14 | struct XMLNode: Equatable, Hashable { 15 | var name: String 16 | var attributes: [String: String] = [:] 17 | var content: String = "" 18 | var children: [XMLNode] = [] 19 | } 20 | 21 | enum BasicXMLParserError: Error, Equatable { 22 | case noContent 23 | case parseError(NSError, Int) 24 | } 25 | 26 | class BasicXMLParser: NSObject, XMLParserDelegate { 27 | private let parser: XMLParser 28 | private var resultStack: [XMLNode] = [XMLNode(name: "", attributes: [:], content: "", children: [])] 29 | private var result: XMLNode? { 30 | return resultStack.first?.children.first 31 | } 32 | 33 | private var prefixes: Set = [] 34 | 35 | init(xml: String) { 36 | parser = XMLParser(data: xml.data(using: .utf8) ?? Data()) 37 | parser.shouldReportNamespacePrefixes = true 38 | } 39 | 40 | func parse() -> Result { 41 | parser.delegate = self 42 | let parseResult = parser.parse() 43 | if parseResult { 44 | guard let result = result else { return .failure(.noContent) } 45 | return .success(result) 46 | } else { 47 | let error = BasicXMLParserError.parseError(parser.parserError! as NSError, parser.lineNumber) 48 | return .failure(error) 49 | } 50 | } 51 | 52 | func parser( 53 | _: XMLParser, 54 | didStartElement elementName: String, 55 | namespaceURI _: String?, 56 | qualifiedName _: String?, 57 | attributes attributeDict: [String: String] = [:] 58 | ) { 59 | var name: String = elementName 60 | for pref in prefixes { 61 | if name.hasPrefix(pref) { 62 | name.removeFirst(pref.count + 1) 63 | break 64 | } 65 | } 66 | 67 | let newNode = XMLNode(name: name, attributes: attributeDict, content: "", children: []) 68 | resultStack.append(newNode) 69 | } 70 | 71 | func parser(_: XMLParser, didEndElement _: String, namespaceURI _: String?, qualifiedName _: String?) { 72 | resultStack[resultStack.count - 2].children.append(resultStack.last!) 73 | resultStack.removeLast() 74 | } 75 | 76 | func parser(_: XMLParser, foundCharacters string: String) { 77 | let contentSoFar = resultStack.last?.content ?? "" 78 | resultStack[resultStack.count - 1].content = contentSoFar + string.trimmingCharacters(in: .whitespacesAndNewlines) 79 | } 80 | 81 | func parser( 82 | _ parser: XMLParser, 83 | parseErrorOccurred parseError: any Error 84 | ) { 85 | print(parseError.localizedDescription) 86 | } 87 | 88 | func parser( 89 | _ parser: XMLParser, 90 | didStartMappingPrefix prefix: String, 91 | toURI namespaceURI: String 92 | ) { 93 | guard !prefix.isEmpty else { return } 94 | if namespaceURI == .trackPointExtensionURL { 95 | prefixes.insert(prefix) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPXKit 2 | 3 | A library for parsing and exporting GPX files depending on Foundation and [Swift Algorithms](https://github.com/apple/swift-algorithms) only. 4 | 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmmllr%2FGPXKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/mmllr/GPXKit) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmmllr%2FGPXKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/mmllr/GPXKit) 7 | 8 | ## Features 9 | 10 | - [x] Parsing GPX files into a track struct 11 | - [x] Exporting a track to a GPX xml 12 | - [x] Support for iOS, macOS & watchOS 13 | - [x] Optionally removes date and time from exported GPX for keeping privacy 14 | - [x] Combine support 15 | - [x] Height Map, geo-bounds, distance, and elevation information for an imported track 16 | - [x] Waypoint support 17 | - [x] Test coverage 18 | - [x] Climb detection 19 | - [x] Grade segmentation 20 | - [x] Support for Garmin trackpoint extensions 21 | - [x] Support for multiple track segements 22 | - [x] Support for track types 23 | 24 | ## Installation 25 | 26 | To use the `GPXKit` library in a SwiftPM project, add the following line to the dependencies in your `Package.swift` file: 27 | 28 | ```swift 29 | .package(url: "https://github.com/mmllr/GPXKit", from: "2.4.0") 30 | ``` 31 | 32 | ## Usage examples 33 | 34 | ### Importing a track 35 | 36 | ```swift 37 | import GPXKit 38 | 39 | let parser = GPXFileParser(xmlString: xml) 40 | switch parser.parse() { 41 | case .success(let track): 42 | doSomethingWith(track) 43 | case .failure(let error): 44 | parseError = error 45 | } 46 | ... 47 | func doSomethingWith(_ track: GPXTrack) { 48 | let formatter = MeasurementFormatter() 49 | formatter.unitStyle = .short 50 | formatter.unitOptions = .naturalScale 51 | formatter.numberFormatter.maximumFractionDigits = 1 52 | let trackGraph = track.graph 53 | print("Track length: \(formatter.string(from: Measurement(value: trackGraph.distance, unit: .meters)))") 54 | print("Track elevation: \(formatter.string(from: Measurement(value: trackGraph.elevationGain, unit: .meters)))") 55 | 56 | for point in track.trackPoints { 57 | print("Lat: \(point.coordinate.latitude), lon: \(point.coordinate.longitude)") 58 | } 59 | } 60 | ``` 61 | 62 | ### Exporting a track 63 | 64 | ```swift 65 | import GPXKit 66 | let track: GPXTrack = ... 67 | let exporter = GPXExporter(track: track, shouldExportDate: false) 68 | print(exporter.xmlString) 69 | ``` 70 | 71 | ### Combine integration 72 | 73 | ```swift 74 | import Combine 75 | import GPXKit 76 | 77 | let url = /// url with gpx 78 | GPXFileParser.load(from: url) 79 | .publisher 80 | .map { track in 81 | // do something with parsed track 82 | } 83 | ``` 84 | 85 | See tests for more usage examples. 86 | 87 | ### Climb detection 88 | 89 | To detect climbs in a track, use the `TrackGraph`s `climb(epsilon:minimumGrade:maxJoinDistance:)` method which returns an array of `Climb` values for given filter parameters. 90 | 91 | ```swift 92 | let track: GPXTrack = ... 93 | let climbs = track.graph.climbs(epsilon: 4.0, minimumGrade: 3.0, maxJoinDistance: 0.0) 94 | // climbs is an array of `Climb` values, describing each climb (start, end, elevation, grade, FIETS score and so on...). 95 | ``` 96 | 97 | ## Documentation 98 | 99 | Project documentation is available at [Swift Package Index](https://swiftpackageindex.com/mmllr/GPXKit/documentation/gpxkit) 100 | 101 | ## Contributing 102 | 103 | Contributions to this project will be more than welcomed. Feel free to add a pull request or open an issue. 104 | If you require a feature that has yet to be available, do open an issue, describing why and what the feature could bring and how it would help you! 105 | -------------------------------------------------------------------------------- /Sources/GPXKit/GradeSegment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// A value describing a grade of a track. A ``TrackGraph`` has an array of ``GradeSegment`` from start to its distance 8 | /// each with a given length and the grade at this distance. 9 | public struct GradeSegment: Sendable { 10 | /// The start in meters of the segment. 11 | public var start: Double 12 | /// The end in meters of the grade segment. 13 | public var end: Double 14 | 15 | /// The elevation in meters at the start of the segment. Defaults to zero. 16 | public var elevationAtStart: Double 17 | 18 | // The elevation in meters at the end of the segment. Defaults to zero. 19 | public var elevationAtEnd: Double 20 | 21 | public init?(start: Double, end: Double, elevationAtStart: Double = 0, elevationAtEnd: Double = 0) { 22 | guard 23 | start < end, 24 | (end - start) * 0.3 >= abs(elevationAtEnd - elevationAtStart) 25 | else { return nil } 26 | self.start = start 27 | self.end = end 28 | self.elevationAtStart = elevationAtStart 29 | self.elevationAtEnd = elevationAtEnd 30 | } 31 | } 32 | 33 | extension GradeSegment: Equatable { 34 | public static func == (lhs: GradeSegment, rhs: GradeSegment) -> Bool { 35 | if lhs.start != rhs.start { 36 | return false 37 | } 38 | if lhs.end != rhs.end { 39 | return false 40 | } 41 | if abs(lhs.elevationAtStart - rhs.elevationAtStart) > 0.1 { 42 | return false 43 | } 44 | 45 | if abs(lhs.elevationAtEnd - rhs.elevationAtEnd) > 0.1 { 46 | return false 47 | } 48 | return true 49 | } 50 | } 51 | 52 | extension GradeSegment: Hashable {} 53 | 54 | extension ClosedRange { 55 | static let allowedGrades: Self = -0.30 ... 0.3 56 | } 57 | 58 | public extension GradeSegment { 59 | struct InvalidGradeError: Error {} 60 | 61 | init?(start: Double, end: Double, grade: Double, elevationAtStart: Double = 0) { 62 | guard ClosedRange.allowedGrades.contains(grade) else { return nil } 63 | self.init( 64 | start: start, 65 | end: end, 66 | elevationAtStart: elevationAtStart, 67 | elevationAtEnd: elevationAtStart + atan(grade) * (end - start) 68 | ) 69 | } 70 | 71 | /// The normalized grade in percent in the range -1...1. 72 | var grade: Double { 73 | guard length > .zero else { return .zero } 74 | // the length is the hypothenuse of the elevation triangle, see 75 | // https://theclimbingcyclist.com/gradients-and-cycling-an-introduction for more details 76 | // grade = gain / horizontal length 77 | let a = (pow(length, 2) - pow(elevationGain, 2)).squareRoot() 78 | return elevationGain / a 79 | } 80 | 81 | /// The length in meters of the segment. 82 | var length: Double { 83 | end - start 84 | } 85 | 86 | /// The elevation gain in meters of the segment. 87 | var elevationGain: Double { 88 | elevationAtEnd - elevationAtStart 89 | } 90 | 91 | mutating func adjust(grade: Double) throws { 92 | guard let new = adjusted(grade: grade) else { throw InvalidGradeError() } 93 | self = new 94 | } 95 | 96 | func adjusted(grade: Double) -> Self? { 97 | return .init( 98 | start: start, 99 | end: end, 100 | elevationAtStart: elevationAtStart, 101 | elevationAtEnd: elevationAtStart + atan(grade) * length 102 | ) 103 | } 104 | 105 | mutating func merge(_ other: Self) { 106 | guard (grade - other.grade).magnitude < 0.003 else { return } 107 | end = other.end 108 | elevationAtEnd = other.elevationAtEnd 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/GPXKit/Climb.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Describes a climb section within a track. 8 | public struct Climb: Hashable, Sendable { 9 | /// The distance in meters from the climbs start to the ``GPXTrack``s origin. 10 | public var start: Double 11 | /// The distance of the end climb in meters from the ``GPXTrack``s origin. 12 | public var end: Double 13 | /// The elevation in meters of the climbs bottom. 14 | public var bottom: Double 15 | /// The elevation in meters of the climbs top. 16 | public var top: Double 17 | /// The total elevation gain in meters of the climb. It may be higher than `top` - `bottom` when a climb has flat or descending 18 | /// sections. 19 | public var totalElevation: Double 20 | /// The average grade (elevation over distance) of the climb in percent in the range {0,1}. 21 | public var grade: Double 22 | /// The maximum grade (elevation over distance) of the climb in percent in the range {0,1}. If the climb was constructed from multiple 23 | /// adjacent climbs it has their maximum grade, otherwise `maxGrade` is equal to `grade`. 24 | public var maxGrade: Double 25 | /// The FIETS score of the climb 26 | /// 27 | /// One way to determine the difficulty of a climb is to use the FIETS formula to calculate a numeric value for the climb. This formula 28 | /// was developed by the Dutch cycling magazine Fiets. The formula is shown below: 29 | /// 30 | /// ``` 31 | /// FIETS Score = (H * H / D * 10) + (T - 1000) / 1000 32 | /// ``` 33 | /// Where: 34 | 35 | /// * **H** is the height of the climb (meters), 36 | /// * **D** is the climb length or distance (meters) 37 | /// * **T** is the altitude at the top (meters). 38 | 39 | /// The second term in the formula is only added when it is positive, that is, for climbs whose top is above 1000m. 40 | /// **NOTE** In GPXKit, the "(T - 1000)/1000" term of the FIETS formula is not added to the climb segments, so climbs can be joined 41 | /// together. 42 | public var score: Double 43 | 44 | /// Initializes a `Climb`. 45 | /// - Parameters: 46 | /// - start: The distance in meters from the `GPXTrack`s start. 47 | /// - end: The distance in meters from the `GOXTracks`s end. 48 | /// - bottom: The elevation in meters at the start of the climb. 49 | /// - top: The elevation in meters at the top of the climb. 50 | /// - totalElevation: The total elevation in meters of the climb (top - bottom in most cases). 51 | /// - grade: The grade (elevation over distance) of the climb in percent in the range {0,1}. 52 | /// - maxGrade: The maximum grade (elevation over distance) of the climb in percent in the range {0,1}. 53 | /// - score: The FIETS Score of the climb. 54 | public init( 55 | start: Double, 56 | end: Double, 57 | bottom: Double, 58 | top: Double, 59 | totalElevation: Double, 60 | grade: Double, 61 | maxGrade: Double, 62 | score: Double 63 | ) { 64 | self.start = start 65 | self.end = end 66 | self.bottom = bottom 67 | self.top = top 68 | self.totalElevation = totalElevation 69 | self.grade = grade 70 | self.maxGrade = maxGrade 71 | self.score = score 72 | } 73 | 74 | /// Initializes a `Climb`. 75 | /// - Parameters: 76 | /// - start: The distance in meters from the `GPXTrack`s start. 77 | /// - end: The distance in meters from the `GOXTracks`s end. 78 | /// - bottom: The elevation in meters at the start of the climb. 79 | /// - top: The elevation in meters at the top of the climb. 80 | public init(start: Double, end: Double, bottom: Double, top: Double) { 81 | let distance = end - start 82 | let elevation = top - bottom 83 | self.start = start 84 | self.end = end 85 | self.bottom = bottom 86 | self.top = top 87 | totalElevation = elevation 88 | grade = elevation / distance 89 | maxGrade = elevation / distance 90 | score = (elevation * elevation) / (distance * 10) + max(0, (top - 1000.0) / 1000.0) 91 | } 92 | } 93 | 94 | public extension Climb { 95 | /// The length in meters of the climb. 96 | var distance: Double { 97 | end - start 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/GPXTrackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import Foundation 7 | import GPXKit 8 | import Testing 9 | 10 | struct GPXTrackTests { 11 | private func givenTrack(with coordinates: [Coordinate]) -> GPXTrack { 12 | GPXTrack(title: "Track", trackPoints: coordinates.map { TrackPoint(coordinate: $0) }, type: nil) 13 | } 14 | 15 | // MARK: - Tests - 16 | 17 | @Test 18 | func testBoundingBoxForEmptyTrack() { 19 | let sut = givenTrack(with: []) 20 | 21 | expectNoDifference(.empty, sut.bounds) 22 | } 23 | 24 | @Test 25 | func testBoundingBoxWithOnePointHasThePointAsBoundingBox() { 26 | let coord = Coordinate(latitude: 54, longitude: 12) 27 | let sut = givenTrack(with: [coord]) 28 | 29 | let expected = GeoBounds( 30 | minLatitude: coord.latitude, 31 | minLongitude: coord.longitude, 32 | maxLatitude: coord.latitude, 33 | maxLongitude: coord.longitude 34 | ) 35 | expectNoDifference(expected, sut.bounds) 36 | } 37 | 38 | @Test 39 | func testBoundsHasTheMinimumAndMaximumCoordinates() { 40 | let sut = givenTrack(with: [ 41 | Coordinate(latitude: -66, longitude: 33), 42 | Coordinate(latitude: -77, longitude: 45), 43 | Coordinate(latitude: 12, longitude: 120), 44 | Coordinate(latitude: 55, longitude: -33), 45 | Coordinate(latitude: 79, longitude: 33), 46 | Coordinate(latitude: -80, longitude: -177) 47 | ]) 48 | 49 | let expected = GeoBounds( 50 | minLatitude: -80, 51 | minLongitude: -177, 52 | maxLatitude: 79, 53 | maxLongitude: 120 54 | ) 55 | expectNoDifference(expected, sut.bounds) 56 | } 57 | 58 | @Test 59 | func testGradeSegments() throws { 60 | let start = Coordinate.leipzig.offset(elevation: 100) 61 | let first: Coordinate = start.offset(distance: 100, grade: 0.1) 62 | let second: Coordinate = first.offset(distance: 100, grade: 0.2) 63 | let third: Coordinate = second.offset(distance: 100, grade: -0.3) 64 | let fourth: Coordinate = third.offset(distance: 50, grade: 0.06) 65 | let sut = try GPXTrack(title: "Track", trackPoints: [ 66 | start, 67 | first, 68 | second, 69 | third, 70 | fourth 71 | ].map { TrackPoint(coordinate: $0) }, elevationSmoothing: .segmentation(50), type: nil) 72 | 73 | let expected: [GradeSegment] = try [ 74 | #require(.init(start: 0, end: 100, elevationAtStart: 100, elevationAtEnd: 109.98)), 75 | #require(.init(start: 100, end: 200, elevationAtStart: 109.98, elevationAtEnd: 129.64)), 76 | #require(.init(start: 200, end: 300, elevationAtStart: 129.64, elevationAtEnd: 100.58)), 77 | #require(.init(start: 300, end: sut.graph.distance, elevationAtStart: 100.58, elevationAtEnd: 103.55)) 78 | ] 79 | expectNoDifference(expected, sut.graph.gradeSegments) 80 | } 81 | 82 | @Test 83 | func testGraphHasTheDistancesFromTheTrackPointsSpeed() { 84 | let start = Date() 85 | let points: [TrackPoint] = [ 86 | .init(coordinate: .dehner, date: start, speed: 1.mps), 87 | .init(coordinate: .dehner.offset(distance: 10, grade: 0), date: start.addingTimeInterval(1), speed: 1.mps), 88 | .init(coordinate: .dehner.offset(distance: 20, grade: 0), date: start.addingTimeInterval(2), speed: 1.mps), 89 | .init(coordinate: .dehner.offset(distance: 25, grade: 0), date: start.addingTimeInterval(3), speed: 1.mps), 90 | .init(coordinate: .dehner.offset(distance: 50, grade: 0), date: start.addingTimeInterval(4), speed: 1.mps) 91 | ] 92 | 93 | let sut = GPXTrack(title: "Track", trackPoints: points, type: nil) 94 | 95 | expectNoDifference(4, sut.graph.distance) 96 | expectNoDifference([ 97 | DistanceHeight(distance: 0, elevation: 0), 98 | DistanceHeight(distance: 1, elevation: 0), 99 | DistanceHeight(distance: 2, elevation: 0), 100 | DistanceHeight(distance: 3, elevation: 0), 101 | DistanceHeight(distance: 4, elevation: 0) 102 | ], sut.graph.heightMap) 103 | expectNoDifference([ 104 | .init(coordinate: points[0].coordinate, distanceInMeters: 0), 105 | .init(coordinate: points[1].coordinate, distanceInMeters: 1), 106 | .init(coordinate: points[2].coordinate, distanceInMeters: 1), 107 | .init(coordinate: points[3].coordinate, distanceInMeters: 1), 108 | .init(coordinate: points[4].coordinate, distanceInMeters: 1) 109 | ], sut.graph.segments) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/GPXKit/TrackPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// A value describing a single data point in a `GPXTrack`. A `TrackPoint` has the latitude, longitude and elevation 8 | /// data along with meta data such as a timestamp or power values. 9 | public struct TrackPoint: Hashable, Sendable { 10 | /// The ``Coordinate`` (latitude, longitude and elevation in meters) 11 | public var coordinate: Coordinate 12 | /// Optional date for a given point. This is the date stamp from a gpx file, recorded from a bicycle computer or 13 | /// running watch. 14 | public var date: Date? 15 | /// Optional power value for a given point in a gpx file, which got recorded from a bicycle computer through a power 16 | /// meter. 17 | public var power: Measurement? 18 | /// Optional cadence value in revolutions per minute for a given point in a gpx file, which got recorded from a 19 | /// bicycle computer through a cadence sensor. 20 | public var cadence: UInt? 21 | /// Optional heartrate value in beats per minute for a given point in a gpx file, which got recorded from a bicycle 22 | /// computer through a heartrate sensor. 23 | public var heartrate: UInt? 24 | /// Optional temperature value for a given point in a gpx file, which got recorded from a bicycle computer through a 25 | /// temperature sensor. 26 | public var temperature: Measurement? 27 | /// Optional speed value for a given point in a gpx file, which got recorded from a bicycle computer through a speed 28 | /// sensor. 29 | public var speed: Measurement? 30 | 31 | /// Initializer 32 | /// You don't need to construct this value by yourself, as it is done by GXPKits track parsing logic. 33 | /// - Parameters: 34 | /// - coordinate: The ``Coordinate`` (latitude, longitude and elevation in meters) 35 | /// - date: Optional date for a point. Defaults to nil. 36 | /// - power: Optional power value for a point. Defaults to nil. 37 | /// - cadence: Optional cadence value for a point. Defaults to nil. 38 | /// - heartrate: Optional heartrate value for a point. Defaults to nil. 39 | /// - temperature: Optional temperature value for a point. Defaults to nil. 40 | /// - speed: Optional speed value for a point. Defaults to nil. 41 | public init( 42 | coordinate: Coordinate, 43 | date: Date? = nil, 44 | power: Measurement? = nil, 45 | cadence: UInt? = nil, 46 | heartrate: UInt? = nil, 47 | temperature: Measurement? = nil, 48 | speed: Measurement? = nil 49 | ) { 50 | self.coordinate = coordinate 51 | self.date = date 52 | self.power = power 53 | self.cadence = cadence 54 | self.heartrate = heartrate 55 | self.temperature = temperature 56 | self.speed = speed 57 | } 58 | } 59 | 60 | extension TrackPoint: GeoCoordinate { 61 | public var latitude: Double { coordinate.latitude } 62 | public var longitude: Double { coordinate.longitude } 63 | } 64 | 65 | extension TrackPoint: HeightMappable { 66 | public var elevation: Double { coordinate.elevation } 67 | } 68 | 69 | protocol DistanceCalculation { 70 | func calculateDistance(to: Self) -> Double 71 | } 72 | 73 | extension TrackPoint: DistanceCalculation { 74 | func calculateDistance(to rhs: TrackPoint) -> Double { 75 | guard 76 | let date, 77 | let delta = rhs.date?.timeIntervalSince(date), 78 | let mps = speed?.converted(to: .metersPerSecond) 79 | else { 80 | return coordinate.distance(to: rhs.coordinate) 81 | } 82 | return mps.value * delta 83 | } 84 | } 85 | 86 | extension Collection where Element: GeoCoordinate, Element: DistanceCalculation, Element: HeightMappable { 87 | func trackSegments() -> [TrackSegment] { 88 | let zipped = zip(self, dropFirst()) 89 | let distances = [0.0] + zipped.map { 90 | $0.calculateDistance(to: $1) 91 | } 92 | return zip(self, distances).map { 93 | TrackSegment( 94 | coordinate: Coordinate(latitude: $0.latitude, longitude: $0.longitude, elevation: $0.elevation), 95 | distanceInMeters: $1 96 | ) 97 | } 98 | } 99 | } 100 | 101 | extension Collection where Element: GeoCoordinate, Element: HeightMappable { 102 | func trackSegments() -> [TrackSegment] { 103 | let zipped = zip(self, dropFirst()) 104 | let distances = [0.0] + zipped.map { 105 | $0.distance(to: $1) 106 | } 107 | return zip(self, distances).map { 108 | TrackSegment( 109 | coordinate: Coordinate(latitude: $0.latitude, longitude: $0.longitude, elevation: $0.elevation), 110 | distanceInMeters: $1 111 | ) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/GPXKit/GPXTrack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum ElevationSmoothing: Sendable, Hashable { 8 | case none 9 | // length in meters 10 | case segmentation(Double) 11 | case smoothing(Int) 12 | case combined(smoothingSampleCount: Int, maxGradeDelta: Double) 13 | } 14 | 15 | /// A value describing a track of geo locations. It has the recorded ``TrackPoint``s, along with metadata of the track, such as recorded 16 | /// date, title, elevation gain, distance, height-map and bounds. 17 | public struct GPXTrack: Hashable, Sendable { 18 | public struct Segment: Hashable, Sendable { 19 | /// The range of indices of the ``TrackPoint/trackPoints``. 20 | public var range: Range 21 | 22 | /// The distance in meters of the segment. 23 | public var distance: Double 24 | 25 | public init(range: Range, distance: Double) { 26 | self.range = range 27 | self.distance = distance 28 | } 29 | } 30 | 31 | /// Optional date stamp of the gpx track 32 | public var date: Date? 33 | /// Waypoint defined for the gpx 34 | public var waypoints: [Waypoint]? 35 | /// Title of the gpx track 36 | public var title: String 37 | /// Description of the gpx track 38 | public var description: String? 39 | /// Array of latitude/longitude/elevation stream values 40 | public var trackPoints: [TrackPoint] 41 | /// `TrackGraph` containing elevation gain, overall distance and the height map of a track. 42 | public var graph: TrackGraph 43 | /// The bounding box enclosing the track 44 | public var bounds: GeoBounds 45 | /// Keywords describing a gpx track 46 | public var keywords: [String] 47 | /// The ``Segment`` of the track. Contains at least one segment 48 | public var segments: [Segment] 49 | /// The type of the gpx track. Defaults to nil. 50 | public var type: String? 51 | 52 | /// Initializes a GPXTrack. 53 | /// - Parameters: 54 | /// - date: The date stamp of the track. Defaults to nil. 55 | /// - waypoints: Array of ``Waypoint`` values. Defaults to nil. 56 | /// - title: String describing the track. 57 | /// - trackPoints: Array of ``TrackPoint``s describing the route. 58 | /// - keywords: Array of `String`s with keywords. Default is an empty array (no keywords). 59 | /// - segments: Array of ``Segment`` values. Defaults to nil. 60 | /// - type: The type of the gpx track. Defaults to nil. 61 | public init( 62 | date: Date? = nil, 63 | waypoints: [Waypoint]? = nil, 64 | title: String, 65 | description: String? = nil, 66 | trackPoints: [TrackPoint], 67 | keywords: [String] = [], 68 | segments: [Segment]? = nil, 69 | type: String? = nil 70 | ) { 71 | self.date = date 72 | self.waypoints = waypoints 73 | self.title = title 74 | self.description = description 75 | self.trackPoints = trackPoints 76 | graph = TrackGraph(coordinates: trackPoints, elevationSmoothing: .none) 77 | bounds = trackPoints.bounds() 78 | self.keywords = keywords 79 | self.segments = segments ?? [.init(range: trackPoints.indices, distance: graph.distance)] 80 | self.type = type 81 | } 82 | 83 | /// Initializes a GPXTrack. You don't need to construct this value by yourself, as it is done by GXPKits track parsing logic. 84 | /// - Parameters: 85 | /// - date: The date stamp of the track. Defaults to nil. 86 | /// - waypoints: Array of ``Waypoint`` values. Defaults to nil. 87 | /// - title: String describing the track. 88 | /// - trackPoints: Array of ``TrackPoint``s describing the route. 89 | /// - keywords: Array of `String`s with keywords. Default is an empty array (no keywords). 90 | /// - elevationSmoothing: The ``ElevationSmoothing`` in meters for the grade segments. Defaults to ``ElevationSmoothing/segmentation(_:)`` with 50 meters. 91 | /// - segments: Array of ``Segment`` values. Defaults to nil. 92 | /// - type: The type of the gpx track. Defaults to nil. 93 | public init( 94 | date: Date? = nil, 95 | waypoints: [Waypoint]? = nil, 96 | title: String, 97 | description: String? = nil, 98 | trackPoints: [TrackPoint], 99 | keywords: [String] = [], 100 | elevationSmoothing: ElevationSmoothing = .segmentation(50), 101 | segments: [Segment]? = nil, 102 | type: String? = nil 103 | ) throws { 104 | self.date = date 105 | self.waypoints = waypoints 106 | self.title = title 107 | self.description = description 108 | self.trackPoints = trackPoints 109 | graph = try TrackGraph(points: trackPoints, elevationSmoothing: elevationSmoothing) 110 | bounds = trackPoints.bounds() 111 | self.keywords = keywords 112 | self.segments = segments ?? [Segment(range: trackPoints.indices, distance: graph.distance)] 113 | self.type = type 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/CollectionExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import Foundation 7 | import GPXKit 8 | import Numerics 9 | import Testing 10 | 11 | @Suite 12 | struct ArrayExtensionsTests { 13 | @Test 14 | func testRemovingNearbyCoordinatesInEmptyCollection() { 15 | let coords: [Coordinate] = [] 16 | 17 | expectNoDifference([], coords.removeIf(closerThan: 1)) 18 | } 19 | 20 | @Test 21 | func testRemovingNearbyCoordinatesWithOneElement() { 22 | let coords: [Coordinate] = [.leipzig] 23 | 24 | expectNoDifference([.leipzig], coords.removeIf(closerThan: 1)) 25 | } 26 | 27 | @Test 28 | func testRemovingNearbyCoordinatesWithTwoElement() { 29 | let coords: [Coordinate] = [.leipzig, .dehner] 30 | 31 | expectNoDifference([.leipzig, .dehner], coords.removeIf(closerThan: 1)) 32 | } 33 | 34 | @Test 35 | func testRemovingDuplicateCoordinates() { 36 | let start = Coordinate.leipzig 37 | let coords: [Coordinate] = [ 38 | start, 39 | start.offset(east: 60), 40 | start.offset(east: 100), 41 | start.offset(north: 120), 42 | start.offset(north: 160), 43 | .postPlatz 44 | ] 45 | 46 | let result = coords.removeIf(closerThan: 50) 47 | expectNoDifference([coords[0], coords[1], coords[3], .postPlatz], result) 48 | } 49 | 50 | @Test 51 | func testSmoothingElevation() { 52 | let start = Coordinate.leipzig.offset(elevation: 200) 53 | let coords: [Coordinate] = stride(from: 0, to: 100, by: 1).map { idx in 54 | start.offset( 55 | north: Double.random(in: 100 ... 1000), 56 | east: Double.random(in: 100 ... 1000), 57 | elevation: Double.random(in: idx.isMultiple(of: 10) ? 500 ... 550 : 100 ... 110) 58 | ) 59 | } 60 | 61 | let avg = coords.map(\.elevation).reduce(0, +) / Double(coords.count) 62 | 63 | for (idx, coord) in coords.smoothedElevation(sampleCount: 50).enumerated() { 64 | assertGeoCoordinateEqual(coord, coords[idx]) 65 | #expect(avg.isApproximatelyEqual(to: coord.elevation, absoluteTolerance: 15)) 66 | } 67 | } 68 | 69 | @Test 70 | func testFlatteningGradeSegments() throws { 71 | let grades: [GradeSegment] = try [ 72 | #require(.init(start: 0, end: 100, elevationAtStart: 50, elevationAtEnd: 60)), 73 | #require(.init(start: 100, end: 200, elevationAtStart: 60, elevationAtEnd: 75)), 74 | #require(GradeSegment(start: 200, end: 270, elevationAtStart: 75, elevationAtEnd: 82)) 75 | ] 76 | 77 | #expect(grades[0].grade.isApproximatelyEqual(to: 0.1, absoluteTolerance: 0.001)) 78 | #expect(grades[1].grade.isApproximatelyEqual(to: 0.15, absoluteTolerance: 0.01)) 79 | #expect(grades[2].grade.isApproximatelyEqual(to: 0.1, absoluteTolerance: 0.001)) 80 | 81 | let second = try #require(grades[1].adjusted(grade: grades[0].grade + 0.01)) 82 | #expect(0.11.isApproximatelyEqual(to: second.grade, absoluteTolerance: 0.001)) 83 | let third = try #require(GradeSegment( 84 | start: 200, 85 | end: 270, 86 | grade: second.grade - 0.01, 87 | elevationAtStart: second.elevationAtEnd 88 | )) 89 | #expect(0.1.isApproximatelyEqual(to: third.grade, absoluteTolerance: 0.001)) 90 | 91 | let expected: [GradeSegment] = [ 92 | grades[0], 93 | second, 94 | third 95 | ] 96 | let actual = try grades.flatten(maxDelta: 0.01) 97 | expectNoDifference(expected, actual) 98 | } 99 | 100 | @Test 101 | func testFlatteningGradeSegmentsWithVeryLargeGradeDifferencesDoesNotResultInNotANumber() throws { 102 | let track = try GPXFileParser(xmlString: .saCalobra).parse().get() 103 | 104 | let graph = TrackGraph(coords: .init(track.trackPoints.map(\.coordinate).prefix(50))) 105 | let actual = try graph.gradeSegments.flatten(maxDelta: 0.01) 106 | 107 | expectNoDifference(0, actual.filter { $0.grade.isNaN }.count) 108 | } 109 | 110 | @Test 111 | func testSmoothingElevationOnSmallCollections() { 112 | let start = Coordinate.leipzig.offset(elevation: 200) 113 | let end = start.offset( 114 | north: Double.random(in: 100 ... 1000), 115 | east: Double.random(in: 100 ... 1000), 116 | elevation: Double.random(in: 500 ... 550) 117 | ) 118 | 119 | expectNoDifference([], [Coordinate]().smoothedElevation(sampleCount: Int.random(in: 2 ... 200))) 120 | expectNoDifference([start], [start].smoothedElevation(sampleCount: Int.random(in: 5 ... 200))) 121 | 122 | let coords: [Coordinate] = [start, end] 123 | let avg = coords.map(\.elevation).reduce(0, +) / Double(coords.count) 124 | 125 | expectNoDifference( 126 | [.init(latitude: start.latitude, longitude: start.longitude, elevation: avg), .init( 127 | latitude: end.latitude, 128 | longitude: end.longitude, 129 | elevation: avg 130 | )], 131 | coords.smoothedElevation(sampleCount: 50) 132 | ) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Sources/GPXKit/TrackGraph+Private.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension TrackGraph { 8 | func findClimbs(epsilon: Double, minimumGrade: Double, maxJoinDistance: Double) -> [Climb] { 9 | let simplified = heightMap.simplify(tolerance: epsilon) 10 | let climbs: [Climb] = zip(simplified, simplified.dropFirst()).compactMap { start, end in 11 | guard end.elevation > start.elevation else { return nil } 12 | let elevation = end.elevation - start.elevation 13 | let distance = end.distance - start.distance 14 | let grade = elevation / distance 15 | guard grade >= minimumGrade else { return nil } 16 | return Climb( 17 | start: start.distance, 18 | end: end.distance, 19 | bottom: start.elevation, 20 | top: end.elevation, 21 | totalElevation: end.elevation - start.elevation, 22 | grade: grade, 23 | maxGrade: grade, 24 | score: (elevation * elevation) / (distance * 10) 25 | ) 26 | } 27 | return join(climbs: climbs, maxJoinDistance: maxJoinDistance) 28 | } 29 | 30 | fileprivate func join(climbs: [Climb], maxJoinDistance: Double) -> [Climb] { 31 | return climbs.reduce(into: []) { joinedClimbs, climb in 32 | guard let last = joinedClimbs.last else { 33 | joinedClimbs.append(climb) 34 | return 35 | } 36 | if (climb.start - last.end) <= maxJoinDistance.magnitude { 37 | let distance = climb.end - last.start 38 | let totalElevation = last.totalElevation + climb.totalElevation 39 | let joined = Climb( 40 | start: last.start, 41 | end: climb.end, 42 | bottom: last.bottom, 43 | top: climb.top, 44 | totalElevation: totalElevation, 45 | grade: totalElevation / distance, 46 | maxGrade: max(last.maxGrade, climb.maxGrade), 47 | score: last.score + climb.score 48 | ) 49 | joinedClimbs[joinedClimbs.count - 1] = joined 50 | } else { 51 | joinedClimbs.append(climb) 52 | } 53 | } 54 | } 55 | } 56 | 57 | protocol Simplifiable { 58 | var x: Double { get } 59 | var y: Double { get } 60 | } 61 | 62 | extension DistanceHeight: Simplifiable { 63 | var x: Double { distance } 64 | var y: Double { elevation } 65 | } 66 | 67 | extension Coordinate: Simplifiable { 68 | var x: Double { latitude } 69 | var y: Double { longitude } 70 | } 71 | 72 | // MARK: - Private implementation - 73 | 74 | private extension Simplifiable { 75 | func squaredDistanceToSegment(_ p1: Self, _ p2: Self) -> Double { 76 | var x = p1.x 77 | var y = p1.y 78 | var dx = p2.x - x 79 | var dy = p2.y - y 80 | 81 | if dx != 0 || dy != 0 { 82 | let deltaSquared = (dx * dx + dy * dy) 83 | let t = ((self.x - p1.x) * dx + (self.y - p1.y) * dy) / deltaSquared 84 | if t > 1 { 85 | x = p2.x 86 | y = p2.y 87 | } else if t > 0 { 88 | x += dx * t 89 | y += dy * t 90 | } 91 | } 92 | 93 | dx = self.x - x 94 | dy = self.y - y 95 | 96 | return dx * dx + dy * dy 97 | } 98 | } 99 | 100 | extension Array where Element: Simplifiable { 101 | func simplify(tolerance: Double) -> Self { 102 | return simplifyDouglasPeucker(self, sqTolerance: tolerance * tolerance) 103 | } 104 | 105 | private func simplifyDPStep(_ points: Self, first: Self.Index, last: Self.Index, sqTolerance: Double, simplified: inout Self) { 106 | guard last > first else { 107 | return 108 | } 109 | var maxSqDistance = sqTolerance 110 | var index = startIndex 111 | 112 | for currentIndex: Self.Index in first + 1 ..< last { 113 | let sqDistance = points[currentIndex].squaredDistanceToSegment(points[first], points[last]) 114 | if sqDistance > maxSqDistance { 115 | maxSqDistance = sqDistance 116 | index = currentIndex 117 | } 118 | } 119 | 120 | if maxSqDistance > sqTolerance { 121 | if (index - first) > 1 { 122 | simplifyDPStep(points, first: first, last: index, sqTolerance: sqTolerance, simplified: &simplified) 123 | } 124 | simplified.append(points[index]) 125 | if (last - index) > 1 { 126 | simplifyDPStep(points, first: index, last: last, sqTolerance: sqTolerance, simplified: &simplified) 127 | } 128 | } 129 | } 130 | 131 | private func simplifyDouglasPeucker(_ points: [Element], sqTolerance: Double) -> [Element] { 132 | guard points.count > 1 else { 133 | return [] 134 | } 135 | 136 | let last = (points.count - 1) 137 | var simplified = [points.first!] 138 | simplifyDPStep(points, first: 0, last: last, sqTolerance: sqTolerance, simplified: &simplified) 139 | simplified.append(points.last!) 140 | 141 | return simplified 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/GPXKit/GPXExporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | #if canImport(FoundationXML) 7 | import FoundationXML 8 | #endif 9 | 10 | /// A class for exporting a `GPXTrack` to an xml string. 11 | public struct GPXExporter: Sendable { 12 | private let track: GPXTrack 13 | private let exportDate: Bool 14 | private let creatorName: String 15 | 16 | /// Initializes a GPXExporter 17 | /// - Parameters: 18 | /// - track: The ``GPXTrack`` to export. 19 | /// - shouldExportDate: Flag indicating whether it should export the timestamps in the track. Set it to false if you want to omit the 20 | /// values. This would decrease the exported xml's file size and protects privacy. Defaults to true. 21 | /// - creatorName: The value for the creator tag in the header. Defaults to GPXKit 22 | /// 23 | /// If the track cannot be exported, the resulting ``GPXExporter/xmlString`` property of the exporter is an empty GPX track xml. 24 | public init(track: GPXTrack, shouldExportDate: Bool = true, creatorName: String = "GPXKit") { 25 | self.track = track 26 | exportDate = shouldExportDate 27 | self.creatorName = creatorName 28 | } 29 | 30 | /// The exported GPX xml string. If the track cannot be exported, its value is an empty GPX track xml. 31 | public var xmlString: String { 32 | return """ 33 | 34 | \(GPXTags.gpx.embed( 35 | attributes: headerAttributes, 36 | [ 37 | GPXTags.metadata.embed([ 38 | metaDataTime, 39 | track.keywords.isEmpty ? "" : GPXTags.keywords.embed(track.keywords.joined(separator: " ")) 40 | ].joined(separator: "\n")), 41 | waypointsXML, 42 | GPXTags.track.embed([ 43 | GPXTags.name.embed(track.title), 44 | track.description.flatMap { GPXTags.description.embed($0) } ?? "", 45 | track.type.flatMap { GPXTags.type.embed($0) } ?? "", 46 | trackXML 47 | ].joined(separator: "\n")) 48 | ].joined(separator: "\n") 49 | )) 50 | """ 51 | } 52 | 53 | private var metaDataTime: String { 54 | guard exportDate, let date = track.date else { return "" } 55 | return GPXTags.time.embed(ISO8601DateFormatter.exporting.string(from: date)) 56 | } 57 | 58 | private var waypointsXML: String { 59 | guard let waypoints = track.waypoints, !waypoints.isEmpty else { return "" } 60 | return waypoints.map { waypoint in 61 | let attributes = [ 62 | GPXAttributes.latitude.assign("\"\(waypoint.coordinate.latitude)\""), 63 | GPXAttributes.longitude.assign("\"\(waypoint.coordinate.longitude)\"") 64 | ].joined(separator: " ") 65 | var children = [String]() 66 | if let name = waypoint.name { 67 | children.append(GPXTags.name.embed(name)) 68 | } 69 | if let comment = waypoint.comment { 70 | children.append(GPXTags.comment.embed(comment)) 71 | } 72 | if let description = waypoint.description { 73 | children.append(GPXTags.description.embed(description)) 74 | } 75 | return GPXTags.waypoint.embed(attributes: attributes, children.joined(separator: "\n")) 76 | }.joined(separator: "\n") 77 | } 78 | 79 | private var trackXML: String { 80 | guard !track.trackPoints.isEmpty else { return "" } 81 | return track.segments.map { 82 | GPXTags.trackSegment.embed( 83 | track.trackPoints[$0.range].map { point in 84 | let attributes = [ 85 | GPXAttributes.latitude.assign("\"\(point.coordinate.latitude)\""), 86 | GPXAttributes.longitude.assign("\"\(point.coordinate.longitude)\"") 87 | ].joined(separator: " ") 88 | let children = [ 89 | GPXTags.elevation.embed(String(format: "%.2f", point.coordinate.elevation)), 90 | exportDate ? point.date.flatMap { 91 | GPXTags.time.embed(ISO8601DateFormatter.exporting.string(from: $0)) 92 | } : nil 93 | ].compactMap { $0 }.joined(separator: "\n") 94 | return GPXTags.trackPoint.embed( 95 | attributes: attributes, 96 | children 97 | ) 98 | }.joined(separator: "\n") 99 | ) 100 | }.joined(separator: "\n") 101 | } 102 | 103 | private var headerAttributes: String { 104 | return """ 105 | creator="\( 106 | creatorName 107 | )" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" 108 | """ 109 | } 110 | } 111 | 112 | extension GPXTags { 113 | func embed(attributes: String = "", _ content: String) -> String { 114 | let openTag = ["\(rawValue)", attributes] 115 | .joined(separator: " ") 116 | .trimmingCharacters(in: .whitespaces) 117 | return "<\(openTag)>\n\(content)\n" 118 | } 119 | } 120 | 121 | extension GPXAttributes { 122 | func assign(_ content: String) -> String { 123 | "\(rawValue)=\(content)" 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/GPXKit/GeoCoordinate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Protocol for describing geo coordinates 8 | /// 9 | /// Types that conform to the ``GeoCoordinate`` protocol can be used with GPXKits utility functions, for example distance or bounds 10 | /// calculations. 11 | /// Adding ``GeoCoordinate`` conformance to your custom types means that your types must provide readable getters for latitude and longitude 12 | /// degree values. 13 | public protocol GeoCoordinate { 14 | /// Latitude value in degrees 15 | var latitude: Double { get } 16 | /// Longitude value in degrees 17 | var longitude: Double { get } 18 | } 19 | 20 | public protocol HeightMappable { 21 | /// Elevation above sea level in meters 22 | var elevation: Double { get } 23 | } 24 | 25 | public extension GeoCoordinate { 26 | /// A range of valid latitude values (from -90 to 90 degrees) 27 | static var validLatitudeRange: ClosedRange { -90 ... 90 } 28 | /// A range of valid longitude values (from -180 to 180 degrees) 29 | static var validLongitudeRange: ClosedRange { -180 ... 180 } 30 | 31 | /// Calculates the distance in meters to another `GeoCoordinate`. 32 | /// - Parameter to: Destination coordinate (given latitude & longitude degrees) to which the distance should be calculated. 33 | /// - Returns: Distance in meters. 34 | func distance(to: any GeoCoordinate) -> Double { 35 | return calculateHaversineDistance(to: to) 36 | } 37 | 38 | /// Performs a mercator projection of a geo coordinate to values in meters along x/y 39 | /// - Returns: A pair of x/y-values in meters. 40 | /// 41 | /// This produces a fast approximation to the truer, but heavier elliptical projection, where the Earth would be projected on a more 42 | /// accurate ellipsoid (flattened on poles). As a consequence, direct measurements of distances in this projection will be 43 | /// approximative, except on the Equator, and the aspect ratios on the rendered map for true squares measured on the surface on Earth 44 | /// will slightly change with latitude and angles not so precisely preserved by this spherical projection. 45 | /// [More details on Wikipedia](https://wiki.openstreetmap.org/wiki/Mercator) 46 | func mercatorProjectionToMeters() -> (x: Double, y: Double) { 47 | let earthRadius = 6378137.0 // meters 48 | let yInMeters: Double = log(tan(.pi / 4.0 + latitude.degreesToRadians / 2.0)) * earthRadius 49 | let xInMeters: Double = longitude.degreesToRadians * earthRadius 50 | return (x: xInMeters, y: -yInMeters) 51 | } 52 | 53 | /// Performs a mercator projection of a geo coordinate to values in degrees 54 | /// - Returns: A pair of x/y-values in latitude/longitude degrees. 55 | /// 56 | /// This produces a fast approximation to the truer, but heavier elliptical projection, where the Earth would be projected on a more 57 | /// accurate ellipsoid (flattened on poles). As a consequence, direct measurements of distances in this projection will be 58 | /// approximative, except on the Equator, and the aspect ratios on the rendered map for true squares measured on the surface on Earth 59 | /// will slightly change with latitude and angles not so precisely preserved by this spherical projection. 60 | /// [More details on Wikipedia](https://wiki.openstreetmap.org/wiki/Mercator) 61 | func mercatorProjectionToDegrees() -> (x: Double, y: Double) { 62 | return (x: longitude, y: -log(tan(latitude.degreesToRadians / 2 + .pi / 4)).radiansToDegrees) 63 | } 64 | } 65 | 66 | public extension GeoCoordinate { 67 | /// Helper method for offsetting a ``GeoCoordinate``. Useful in tests or for tweaking a known location 68 | /// - Parameters: 69 | /// - north: The offset in meters in _vertical_ direction as seen on a map. Use negative values to go _upwards_ on a globe, positive 70 | /// values for moving downwards. 71 | /// - east: The offset in meters in _horizontal_ direction as seen on a map. Use negative values to go to the _west_ on a globe, 72 | /// positive values for moving in the _eastern_ direction. 73 | /// - Returns: A new `Coordinate` value, offset by north and east values in meters. 74 | /// 75 | /// ```swift 76 | /// let position = Coordinate(latitude: 51.323331, longitude: 12.368279) 77 | /// position.offset(east: 60), 78 | /// position.offset(east: -100), 79 | /// position.offset(north: 120), 80 | /// position.offset(north: -160), 81 | /// ``` 82 | /// 83 | /// See [here](https://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters) for 84 | /// more details. 85 | func offset(north: Double = 0, east: Double = 0) -> Coordinate { 86 | // Earth’s radius, sphere 87 | let radius: Double = 6378137 88 | 89 | // Coordinate offsets in radians 90 | let dLat = north / radius 91 | let dLon = east / (radius * cos(.pi * latitude / 180)) 92 | 93 | // OffsetPosition, decimal degrees 94 | return Coordinate( 95 | latitude: latitude + dLat * 180 / .pi, 96 | longitude: longitude + dLon * 180 / .pi 97 | ) 98 | } 99 | } 100 | 101 | public extension GeoCoordinate { 102 | /// Calculates the bearing of the coordinate to a second 103 | /// - Parameter target: The second coordinate 104 | /// - Returns: The bearing to `target`in degrees 105 | func bearing(target: Coordinate) -> Double { 106 | let lat1 = latitude.degreesToRadians 107 | let lon1 = longitude.degreesToRadians 108 | let lat2 = target.latitude.degreesToRadians 109 | let lon2 = target.longitude.degreesToRadians 110 | 111 | let dLon = lon2 - lon1 112 | 113 | let y = sin(dLon) * cos(lat2) 114 | let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) 115 | let radiansBearing = atan2(y, x) 116 | 117 | return radiansBearing.radiansToDegrees 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/GPXKit/CollectionExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public extension Collection where Element: GeoCoordinate { 8 | /// Creates a bounding box from a collection of ``GeoCoordinate``s. 9 | /// - Returns: The 2D representation of the bounding box as ``GeoBounds`` value. 10 | func bounds() -> GeoBounds { 11 | reduce(GeoBounds.empty) { bounds, coord in 12 | GeoBounds( 13 | minLatitude: Swift.min(bounds.minLatitude, coord.latitude), 14 | minLongitude: Swift.min(bounds.minLongitude, coord.longitude), 15 | maxLatitude: Swift.max(bounds.maxLatitude, coord.latitude), 16 | maxLongitude: Swift.max(bounds.maxLongitude, coord.longitude) 17 | ) 18 | } 19 | } 20 | } 21 | 22 | #if canImport(MapKit) && canImport(CoreLocation) && !os(watchOS) 23 | import CoreLocation 24 | import MapKit 25 | 26 | public extension Collection where Element: GeoCoordinate { 27 | /// Creates a `MKPolyline` form a collection of `GeoCoordinates` 28 | /// 29 | /// Important: Only available on iOS and macOS targets. 30 | var polyLine: MKPolyline { 31 | let coords = map(CLLocationCoordinate2D.init) 32 | return MKPolyline(coordinates: coords, count: coords.count) 33 | } 34 | 35 | /// Creates a `CGPath` form a collection of `GeoCoordinates` using an MKMPolylineRenderer. Nil if no path could be created. 36 | /// 37 | /// Important: Only available on iOS and macOS targets. 38 | var path: CGPath? { 39 | let renderer = MKPolylineRenderer(polyline: polyLine) 40 | return renderer.path 41 | } 42 | } 43 | 44 | #endif 45 | 46 | #if canImport(CoreGraphics) 47 | import CoreGraphics 48 | 49 | public extension Collection where Element: GeoCoordinate { 50 | /// Creates a path from the collection of `GeoCoordinate`s. Useful if you want to draw a 2D image of a track. 51 | /// - Parameter normalized: Flag indicating if the paths values should be normalized into the range 0...1. If true, the resulting values 52 | /// in the path are mapped to value in 0...1 coordinates space, otherwise the values from the geo coordinates. Defaults to true. 53 | /// - Returns: A CGPath containing a projected 2D-representation of the geo coordinates. 54 | func path(normalized: Bool = true) -> CGPath { 55 | var min = CGPoint(x: CGFloat.greatestFiniteMagnitude, y: CGFloat.greatestFiniteMagnitude) 56 | var max = CGPoint(x: -CGFloat.greatestFiniteMagnitude, y: -CGFloat.greatestFiniteMagnitude) 57 | var points: [CGPoint] = [] 58 | points.reserveCapacity(count) 59 | for coord in self { 60 | let proj = coord.mercatorProjectionToDegrees() 61 | points.append(CGPoint(x: proj.x, y: proj.y)) 62 | min.x = Swift.min(min.x, CGFloat(proj.x)) 63 | min.y = Swift.min(min.y, CGFloat(proj.y)) 64 | max.x = Swift.max(max.x, CGFloat(proj.x)) 65 | max.y = Swift.max(max.y, CGFloat(proj.y)) 66 | } 67 | let width = max.x - min.x 68 | let height = max.y - min.y 69 | let downScale: CGFloat = 1.0 / Swift.max(width, height) 70 | let scaleTransform = normalized ? CGAffineTransform(scaleX: downScale, y: downScale) : .identity 71 | let positionTransform = CGAffineTransform(translationX: -min.x, y: -min.y) 72 | let combined = positionTransform.concatenating(scaleTransform) 73 | let path = CGMutablePath() 74 | path.addLines(between: points, transform: combined) 75 | return path 76 | } 77 | } 78 | #endif 79 | 80 | public extension Collection where Element: GeoCoordinate { 81 | /// Helper for removing points from a collection if the are closer than a specified threshold. 82 | /// - Parameter meters: The threshold predicate in meters for removing points. A point is removed if it is closer to its predecessor 83 | /// than this value. 84 | /// - Returns: An array of `Coordinate` values, each having a minimum distance to their predecessors of least closerThan meters. 85 | /// 86 | /// Important: The elevation value of the returned `Coordinate` array is always zero. 87 | func removeIf(closerThan meters: Double) -> [Coordinate] { 88 | guard count > 2 else { return map { Coordinate(latitude: $0.latitude, longitude: $0.longitude) } } 89 | 90 | return reduce([Coordinate(latitude: self[startIndex].latitude, longitude: self[startIndex].longitude)]) { coords, coord in 91 | if coords.last!.distance(to: coord) > meters { 92 | return coords + [Coordinate(latitude: coord.latitude, longitude: coord.longitude)] 93 | } 94 | return coords 95 | } 96 | } 97 | } 98 | 99 | public extension Array where Element == Coordinate { 100 | /// Helper for simplifying points from a collection if the are closer than a specified threshold. 101 | /// - Parameter threshold: The threshold predicate in for removing points. A point is removed if it is closer to its neighboring segment 102 | /// according to the [Ramer-Douglas-Peucker algorithm](https://en.wikipedia.org/wiki/Ramer–Douglas–Peucker_algorithm). 103 | /// - Returns: An array of `Coordinate` values. 104 | func simplifyRDP(threshold epsilon: Double) -> [Coordinate] { 105 | simplify(tolerance: epsilon) 106 | } 107 | } 108 | 109 | public extension RandomAccessCollection where Element == Coordinate, Index == Int { 110 | func smoothedElevation(sampleCount: Int = 5) -> [Element] { 111 | guard count > 1 else { return Array(self) } 112 | let smoothingSize = (sampleCount / 2).clamped(to: .safe(lower: 2, upper: count / 2)) 113 | 114 | return indices.reduce(into: [Element]()) { result, idx in 115 | let range = range(idx: idx, smoothingSize: smoothingSize, isLap: self.isLap) 116 | var updated = self[idx] 117 | updated.elevation = averageElevation(range: range) 118 | 119 | result.append(updated) 120 | } 121 | } 122 | 123 | /// Returns `true` if the distance between the first and the last is less than 50 meters. Returns `false` for empty collections. 124 | var isLap: Bool { 125 | guard let first, let last else { return false } 126 | return first.distance(to: last) < 50 127 | } 128 | 129 | private func range(idx: Int, smoothingSize: Int, isLap: Bool) -> ClosedRange { 130 | if isLap { 131 | idx - smoothingSize ... idx + smoothingSize 132 | } else { 133 | Swift.max(startIndex, idx - smoothingSize) ... Swift.min(idx + smoothingSize, endIndex - 1) 134 | } 135 | } 136 | 137 | private func averageElevation(range: ClosedRange) -> Double { 138 | if range.lowerBound >= startIndex && range.upperBound < endIndex { 139 | return self[range].map(\.elevation).reduce(0, +) / Double(range.count) 140 | } 141 | var average: Double = 0 142 | for idx in range { 143 | if idx < startIndex { 144 | average += self[(idx + count) % count].elevation 145 | } else if idx >= endIndex { 146 | average += self[idx % count].elevation 147 | } else { 148 | average += self[idx].elevation 149 | } 150 | } 151 | return average / Double(range.count) 152 | } 153 | } 154 | 155 | public extension [GradeSegment] { 156 | func flatten(maxDelta: Double) throws -> Self { 157 | guard !isEmpty else { return self } 158 | var result: [Element] = [] 159 | for idx in indices { 160 | var segment = self[idx] 161 | if let previous = result.last { 162 | try segment.alignGrades(previous: previous, maxDelta: maxDelta) 163 | } 164 | result.append(segment) 165 | } 166 | return result 167 | } 168 | } 169 | 170 | extension GradeSegment { 171 | mutating func alignGrades(previous: GradeSegment, maxDelta: Double) throws { 172 | if elevationAtStart != previous.elevationAtEnd { 173 | let delta = (elevationAtStart - previous.elevationAtEnd) 174 | elevationAtStart -= delta 175 | elevationAtEnd -= delta 176 | } 177 | let deltaSlope = grade - previous.grade 178 | if abs(deltaSlope) > maxDelta { 179 | if deltaSlope >= 0 { 180 | try adjust(grade: previous.grade + maxDelta) 181 | } else if deltaSlope < 0 { 182 | try adjust(grade: previous.grade - maxDelta) 183 | } 184 | // TODO: Test me! 185 | if elevationAtStart < 0 { 186 | elevationAtStart = 0 187 | } 188 | if elevationAtEnd < 0 { 189 | elevationAtEnd = 0 190 | } 191 | } 192 | } 193 | } 194 | 195 | private extension Comparable { 196 | func clamped(to limits: ClosedRange) -> Self { 197 | min(max(self, limits.lowerBound), limits.upperBound) 198 | } 199 | } 200 | 201 | extension ClosedRange { 202 | static func safe(lower: Bound, upper: Bound) -> Self { 203 | Swift.min(lower, upper) ... Swift.max(lower, upper) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Sources/GPXKit/DistanceCalculation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension FloatingPoint { 8 | var degreesToRadians: Self { self * .pi / 180 } 9 | var radiansToDegrees: Self { self * 180 / .pi } 10 | } 11 | 12 | /// Error indicating that the distance could not be computed within the maximal number of iterations. 13 | public enum ConvergenceError: Error, Sendable { 14 | case notConverged(maxIter: UInt, tol: Double, eps: Double) 15 | } 16 | 17 | /// [WGS 84 ellipsoid](https://en.wikipedia.org/wiki/World_Geodetic_System) definition 18 | public let wgs84 = (a: 6378137.0, f: 1 / 298.257223563) 19 | 20 | /// π (for convenience) 21 | private let pi = Double.pi 22 | 23 | public extension GeoCoordinate { 24 | /// 25 | /// Compute the distance between two points on an ellipsoid. 26 | /// 27 | /// - Parameters: 28 | /// - x: first point with latitude and longitude in radiant. 29 | /// - y: second point with latitude and longitude in radiant. 30 | /// - tol: tolerance for the computed distance (in meters) 31 | /// - maxIter: maximal number of iterations 32 | /// - a: first ellipsoid parameter in meters (defaults to WGS-84 parameter) 33 | /// - f: second ellipsoid parameter in meters (defaults to WGS-84 parameter) 34 | /// 35 | /// - Returns: distance between `x` and `y` in meters. 36 | /// 37 | /// - Throws: 38 | /// - ``ConvergenceError/notConverged(maxIter:tol:eps:)`` if the distance computation does not converge within `maxIter` iterations. 39 | /// 40 | /// The ellipsoid parameters default to the WGS-84 parameters. 41 | /// [Details](https://www.movable-type.co.uk/scripts/latlong-vincenty.html). 42 | /// [Sample implementation in Swift](https://github.com/dastrobu/vincenty/blob/master/Sources/vincenty/vincenty.swift). 43 | func distanceVincenty( 44 | to: any GeoCoordinate, 45 | tol: Double = 1e-12, 46 | maxIter: UInt = 200, 47 | ellipsoid: (a: Double, f: Double) = wgs84 48 | ) throws -> Double { 49 | assert(tol > 0, "tol '\(tol)' ≤ 0") 50 | 51 | // validate lat and lon values 52 | assert( 53 | latitude.degreesToRadians >= -pi / 2 && latitude.degreesToRadians <= pi / 2, 54 | "latitude '\(latitude.degreesToRadians)' outside [-π/2, π]" 55 | ) 56 | assert( 57 | to.latitude.degreesToRadians >= -pi / 2 && to.latitude.degreesToRadians <= pi / 2, 58 | "to.latitude '\(to.latitude.degreesToRadians)' outside [-π/2, π]" 59 | ) 60 | assert( 61 | longitude.degreesToRadians >= -pi && longitude.degreesToRadians <= pi, 62 | "longitude '\(to.longitude.degreesToRadians)' outside [-π, π]" 63 | ) 64 | assert( 65 | to.longitude.degreesToRadians >= -pi && to.longitude.degreesToRadians <= pi, 66 | "to.longitude '\(to.longitude.degreesToRadians)' outside [-π, π]" 67 | ) 68 | 69 | // shortcut for zero distance 70 | if latitude == to.latitude && 71 | longitude == to.longitude 72 | { 73 | return 0.0 74 | } 75 | 76 | // compute ellipsoid constants 77 | let A: Double = ellipsoid.a 78 | let F: Double = ellipsoid.f 79 | let B: Double = (1 - F) * A 80 | let C: Double = (A * A - B * B) / (B * B) 81 | 82 | let u_x: Double = atan((1 - F) * tan(latitude.degreesToRadians)) 83 | let sin_u_x: Double = sin(u_x) 84 | let cos_u_x: Double = cos(u_x) 85 | 86 | let u_y: Double = atan((1 - F) * tan(to.latitude.degreesToRadians)) 87 | let sin_u_y: Double = sin(u_y) 88 | let cos_u_y: Double = cos(u_y) 89 | 90 | let l: Double = to.longitude.degreesToRadians - longitude.degreesToRadians 91 | 92 | var lambda: Double = l, tmp = 0.0 93 | var q = 0.0, p = 0.0, sigma = 0.0, sin_alpha = 0.0, cos2_alpha = 0.0 94 | var c = 0.0, sin_sigma = 0.0, cos_sigma = 0.0, cos_2sigma = 0.0 95 | 96 | for _ in 0 ..< maxIter { 97 | tmp = cos(lambda) 98 | q = cos_u_y * sin(lambda) 99 | p = cos_u_x * sin_u_y - sin_u_x * cos_u_y * tmp 100 | sin_sigma = sqrt(q * q + p * p) 101 | cos_sigma = sin_u_x * sin_u_y + cos_u_x * cos_u_y * tmp 102 | sigma = atan2(sin_sigma, cos_sigma) 103 | 104 | // catch zero division problem 105 | if sin_sigma == 0.0 { 106 | sin_sigma = Double.leastNonzeroMagnitude 107 | } 108 | 109 | sin_alpha = (cos_u_x * cos_u_y * sin(lambda)) / sin_sigma 110 | cos2_alpha = 1 - sin_alpha * sin_alpha 111 | cos_2sigma = cos_sigma - (2 * sin_u_x * sin_u_y) / cos2_alpha 112 | 113 | // check for nan 114 | if cos_2sigma.isNaN { 115 | cos_2sigma = 0.0 116 | } 117 | 118 | c = F / 16.0 * cos2_alpha * (4 + F * (4 - 3 * cos2_alpha)) 119 | tmp = lambda 120 | lambda = ( 121 | l + (1 - c) * F * sin_alpha 122 | * ( 123 | sigma + c * sin_sigma 124 | * ( 125 | cos_2sigma + c * cos_sigma 126 | * ( 127 | -1 + 2 * cos_2sigma * cos_2sigma 128 | ) 129 | ) 130 | ) 131 | ) 132 | 133 | if fabs(lambda - tmp) < tol { 134 | break 135 | } 136 | } 137 | let eps: Double = fabs(lambda - tmp) 138 | if eps >= tol { 139 | throw ConvergenceError.notConverged(maxIter: maxIter, tol: tol, eps: eps) 140 | } 141 | 142 | let uu: Double = cos2_alpha * C 143 | let a: Double = 1 + uu / 16384 * (4096 + uu * (-768 + uu * (320 - 175 * uu))) 144 | let b: Double = uu / 1024 * (256 + uu * (-128 + uu * (74 - 47 * uu))) 145 | let delta_sigma: Double = ( 146 | b * sin_sigma 147 | * ( 148 | cos_2sigma + 1.0 / 4.0 * b 149 | * ( 150 | cos_sigma * (-1 + 2 * cos_2sigma * cos_2sigma) 151 | - 1.0 / 6.0 * b * cos_2sigma 152 | * (-3 + 4 * sin_sigma * sin_sigma) 153 | * (-3 + 4 * cos_2sigma * cos_2sigma) 154 | ) 155 | ) 156 | ) 157 | 158 | return B * a * (sigma - delta_sigma) 159 | } 160 | 161 | /// Calculates the distance in meters to a coordinate using the **haversine** formula. 162 | /// - Parameter to: The ``GeoCoordinate`` to which the distance should be calculated. 163 | /// - Returns: Distance in meters 164 | /// 165 | /// [Details](https://www.movable-type.co.uk/scripts/latlong.html) 166 | internal func calculateHaversineDistance(to: any GeoCoordinate) -> Double { 167 | let R = 6371e3 // metres 168 | let φ1 = latitude.degreesToRadians 169 | let φ2 = to.latitude.degreesToRadians 170 | let Δφ = (to.latitude - latitude).degreesToRadians 171 | let Δλ = (to.longitude - longitude).degreesToRadians 172 | 173 | let a = sin(Δφ / 2) * sin(Δφ / 2) + 174 | cos(φ1) * cos(φ2) * 175 | sin(Δλ / 2) * sin(Δλ / 2) 176 | let c = 2 * atan2(sqrt(a), sqrt(1 - a)) 177 | let d = R * c 178 | return d 179 | } 180 | 181 | /// Calculates the radius in meters around a coordinate for a latitude delta. 182 | /// 183 | /// One degree of latitude is always approximately 111 kilometers (69 miles). So the radius can derived from the delta (the coordinates 184 | /// latitude minus latitudeDelta/2). 185 | /// - Parameter latitudeDelta: The latitude delta. 186 | /// - Returns: The radius in meters. 187 | func radiusInMeters(latitudeDelta: Double) -> Double { 188 | let topCentralLat: Double = latitude - latitudeDelta / 2 189 | let topCentralLocation = Coordinate(latitude: topCentralLat, longitude: longitude, elevation: 0) 190 | return distance(to: topCentralLocation) 191 | } 192 | 193 | /// Calculates the `GeoBounds` for a `GeoCoordinate` around a given radius in meters. 194 | /// 195 | /// [Details on Jan Philip Matuscheks website](http://janmatuschek.de/LatitudeLongitudeBoundingCoordinates#Latitude) 196 | /// - Parameter distanceInMeters: Distance in meters around the coordinate. 197 | /// - Returns: A `GeoBounds` value or nil if no bounds could be calculated (if distanceInMeters is below zero). 198 | func bounds(distanceInMeters: Double) -> GeoBounds? { 199 | guard distanceInMeters >= 0.0 else { return nil } 200 | 201 | // angular distance is radians on a great circle 202 | let earthRadius = 6378137.0 // meters 203 | let radDist = distanceInMeters / earthRadius 204 | let radLatitude = latitude.degreesToRadians 205 | let radLongitude = longitude.degreesToRadians 206 | 207 | let MinLatitude = -90.degreesToRadians // -PI/2 208 | let MaxLatitude = 90.degreesToRadians // PI/2 209 | let MinLongitude = -180.degreesToRadians // -PI 210 | let MaxLongitude = 180.degreesToRadians // PI 211 | 212 | var minLat: Double = radLatitude - radDist 213 | var maxLat: Double = radLatitude + radDist 214 | 215 | var minLon: Double, maxLon: Double 216 | 217 | if minLat > MinLatitude, maxLat < MaxLatitude { 218 | let deltaLon = asin(sin(radDist) / cos(radLatitude)) 219 | minLon = radLongitude - deltaLon 220 | 221 | if minLon < MinLongitude { minLon += 2 * .pi } 222 | maxLon = radLongitude + deltaLon 223 | if maxLon > MaxLongitude { maxLon -= 2 * .pi } 224 | } else { 225 | minLat = max(minLat, MinLatitude) 226 | maxLat = min(maxLat, MaxLatitude) 227 | minLon = MinLongitude 228 | maxLon = MaxLongitude 229 | } 230 | 231 | return GeoBounds( 232 | minLatitude: minLat.radiansToDegrees, 233 | minLongitude: minLon.radiansToDegrees, 234 | maxLatitude: maxLat.radiansToDegrees, 235 | maxLongitude: maxLon.radiansToDegrees 236 | ) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import Foundation 7 | import Numerics 8 | import Testing 9 | 10 | @testable import GPXKit 11 | #if canImport(FoundationXML) 12 | import FoundationXML 13 | #endif 14 | 15 | struct TestGPXPoint: Hashable, GeoCoordinate { 16 | var latitude: Double 17 | var longitude: Double 18 | var elevation: Double? 19 | 20 | @inlinable 21 | func with(_ block: (inout Self) throws -> Void) rethrows -> Self { 22 | var copy = self 23 | try block(©) 24 | return copy 25 | } 26 | } 27 | 28 | private nonisolated(unsafe) let iso8601Formatter: ISO8601DateFormatter = { 29 | let formatter = ISO8601DateFormatter() 30 | formatter.formatOptions = .withInternetDateTime 31 | return formatter 32 | }() 33 | 34 | private nonisolated(unsafe) let importFormatter: ISO8601DateFormatter = { 35 | let formatter = ISO8601DateFormatter() 36 | formatter.formatOptions = .withInternetDateTime 37 | return formatter 38 | }() 39 | 40 | private nonisolated(unsafe) let fractionalFormatter: ISO8601DateFormatter = { 41 | let formatter = ISO8601DateFormatter() 42 | if #available(macOS 10.13, iOS 12, watchOS 5, tvOS 11.0, *) { 43 | formatter.formatOptions = [.withFractionalSeconds, .withInternetDateTime] 44 | } else { 45 | formatter.formatOptions = .withInternetDateTime 46 | } 47 | return formatter 48 | }() 49 | 50 | func expectedDate(for dateString: String) -> Date { 51 | if let date = importFormatter.date(from: dateString) { 52 | return date 53 | } else { 54 | return fractionalFormatter.date(from: dateString)! 55 | } 56 | } 57 | 58 | func expectedString(for date: Date) -> String { 59 | return iso8601Formatter.string(from: date) 60 | } 61 | 62 | func givenTrackPoints(_ count: Int) -> [TrackPoint] { 63 | let date = Date() 64 | 65 | return (1 ..< count).map { sec in 66 | TrackPoint(coordinate: .random, date: date + TimeInterval(sec)) 67 | } 68 | } 69 | 70 | extension String { 71 | var strippedLines: String { 72 | split(separator: "\n") 73 | .map { 74 | $0.trimmingCharacters(in: .whitespaces) 75 | }.joined(separator: "\n") 76 | } 77 | } 78 | 79 | extension Coordinate { 80 | static var random: Coordinate { 81 | Coordinate( 82 | latitude: Double.random(in: -90 ..< 90), 83 | longitude: Double.random(in: -180 ..< 180), 84 | elevation: Double.random(in: 1 ..< 100) 85 | ) 86 | } 87 | 88 | func offset(north: Double = 0, east: Double = 0, elevation: Double) -> Self { 89 | var offset = self.offset(north: north, east: east) 90 | offset.elevation = self.elevation + elevation 91 | return offset 92 | } 93 | 94 | func offset(distance: Double, grade: Double) -> Self { 95 | // elevation = grade * distance 96 | return offset(east: distance, elevation: distance * atan(grade)) 97 | } 98 | 99 | func offset(elevation: Double, grade: Double) -> Self { 100 | return offset(east: (pow(elevation, 2) + pow(elevation / grade, 2)).squareRoot(), elevation: elevation) 101 | } 102 | 103 | init(_ testPoint: TestGPXPoint) { 104 | self.init(latitude: testPoint.latitude, longitude: testPoint.longitude, elevation: testPoint.elevation ?? 0) 105 | } 106 | } 107 | 108 | extension TestGPXPoint { 109 | static var random: TestGPXPoint { 110 | TestGPXPoint( 111 | latitude: Double.random(in: -90 ..< 90), 112 | longitude: Double.random(in: -180 ..< 180), 113 | elevation: Bool.random() ? Double.random(in: 1 ..< 100) : nil 114 | ) 115 | } 116 | 117 | func offset(north: Double = 0, east: Double = 0) -> Self { 118 | // Earth’s radius, sphere 119 | let radius: Double = 6378137 120 | 121 | // Coordinate offsets in radians 122 | let dLat = north / radius 123 | let dLon = east / (radius * cos(.pi * latitude / 180)) 124 | 125 | // OffsetPosition, decimal degrees 126 | return .init( 127 | latitude: latitude + dLat * 180 / .pi, 128 | longitude: longitude + dLon * 180 / .pi, 129 | elevation: elevation 130 | ) 131 | } 132 | } 133 | 134 | extension TrackPoint { 135 | func expectedXMLNode(withDate: Bool = false) -> GPXKit.XMLNode { 136 | XMLNode( 137 | name: GPXTags.trackPoint.rawValue, 138 | attributes: [ 139 | GPXAttributes.latitude.rawValue: "\(coordinate.latitude)", 140 | GPXAttributes.longitude.rawValue: "\(coordinate.longitude)" 141 | ], 142 | children: [ 143 | XMLNode( 144 | name: GPXTags.elevation.rawValue, 145 | content: String(format: "%.2f", coordinate.elevation) 146 | ), 147 | withDate ? date.flatMap { 148 | XMLNode( 149 | name: GPXTags.time.rawValue, 150 | content: expectedString(for: $0) 151 | ) 152 | } : nil 153 | ].compactMap { $0 } 154 | ) 155 | } 156 | } 157 | 158 | func assertDatesEqual( 159 | _ expected: Date?, 160 | _ actual: Date?, 161 | granularity: Calendar.Component = .nanosecond, 162 | fileID: StaticString = #fileID, 163 | filePath: StaticString = #filePath, 164 | line: UInt = #line, 165 | column: UInt = #column, 166 | sourceLocation: SourceLocation = #_sourceLocation 167 | ) { 168 | guard let lhs = expected, let rhs = actual else { 169 | expectNoDifference( 170 | expected, 171 | actual, 172 | "Dates are not equal - expected: \(String(describing: expected)), actual: \(String(describing: actual))", 173 | fileID: fileID, 174 | filePath: filePath, 175 | line: line, 176 | column: column 177 | ) 178 | return 179 | } 180 | #expect( 181 | Calendar.autoupdatingCurrent 182 | .isDate(lhs, equalTo: rhs, toGranularity: granularity), 183 | "Expected dates to be equal - expected: \(lhs), actual: \(rhs)", 184 | sourceLocation: sourceLocation 185 | ) 186 | } 187 | 188 | func assertTracksAreEqual( 189 | _ expected: GPXTrack, 190 | _ actual: GPXTrack, 191 | fileID: StaticString = #fileID, 192 | filePath: StaticString = #filePath, 193 | line: UInt = #line, 194 | column: UInt = #column 195 | ) { 196 | assertDatesEqual(expected.date, actual.date, fileID: fileID, filePath: filePath, line: line, column: column) 197 | expectNoDifference(expected.title, actual.title, fileID: fileID, filePath: filePath, line: line, column: column) 198 | expectNoDifference(expected.description, actual.description, fileID: fileID, filePath: filePath, line: line, column: column) 199 | expectNoDifference(expected.keywords, actual.keywords, fileID: fileID, filePath: filePath, line: line, column: column) 200 | expectNoDifference(expected.trackPoints, actual.trackPoints, fileID: fileID, filePath: filePath, line: line, column: column) 201 | expectNoDifference(expected.graph, actual.graph, fileID: fileID, filePath: filePath, line: line, column: column) 202 | expectNoDifference(expected.bounds, actual.bounds, fileID: fileID, filePath: filePath, line: line, column: column) 203 | expectNoDifference(expected.segments, actual.segments, fileID: fileID, filePath: filePath, line: line, column: column) 204 | } 205 | 206 | /* 207 | public struct XMLNode: Equatable, Hashable { 208 | var name: String 209 | var attributes: [String: String] = [:] 210 | var content: String = "" 211 | public var children: [XMLNode] = [] 212 | } 213 | */ 214 | func assertNodesAreEqual( 215 | _ expected: GPXKit.XMLNode, 216 | _ actual: GPXKit.XMLNode, 217 | fileID: StaticString = #fileID, 218 | filePath: StaticString = #filePath, 219 | line: UInt = #line, 220 | column: UInt = #column 221 | ) { 222 | expectNoDifference(expected.content, actual.content, fileID: fileID, filePath: filePath, line: line, column: column) 223 | expectNoDifference(expected.content, actual.content, fileID: fileID, filePath: filePath, line: line, column: column) 224 | expectNoDifference(expected.attributes, actual.attributes, fileID: fileID, filePath: filePath, line: line, column: column) 225 | expectNoDifference(expected.children, actual.children, fileID: fileID, filePath: filePath, line: line, column: column) 226 | } 227 | 228 | func assertGeoCoordinateEqual( 229 | _ expected: any GeoCoordinate, 230 | _ actual: any GeoCoordinate, 231 | accuracy: Double = 0.0001, 232 | sourceLocation: SourceLocation = #_sourceLocation 233 | ) { 234 | #expect( 235 | expected.latitude.isApproximatelyEqual(to: actual.latitude, absoluteTolerance: accuracy), 236 | "Expected latitude: \(expected.latitude), got \(actual.latitude)", 237 | sourceLocation: sourceLocation 238 | ) 239 | #expect( 240 | expected.longitude.isApproximatelyEqual(to: actual.longitude, absoluteTolerance: accuracy), 241 | "Expected longitude: \(expected.longitude), got \(actual.longitude)", 242 | sourceLocation: sourceLocation 243 | ) 244 | } 245 | 246 | func assertGeoCoordinatesEqual( 247 | _ expected: T, 248 | _ acutal: T, 249 | accuracy: Double = 0.00001, 250 | sourceLocation: SourceLocation = #_sourceLocation, 251 | fileID: StaticString = #fileID, 252 | filePath: StaticString = #filePath, 253 | line: UInt = #line, 254 | column: UInt = #column 255 | ) where T.Element: GeoCoordinate { 256 | expectNoDifference(expected.count, acutal.count, fileID: fileID, filePath: filePath, line: line, column: column) 257 | for (lhs, rhs) in zip(expected, acutal) { 258 | assertGeoCoordinateEqual(lhs, rhs, accuracy: accuracy, sourceLocation: sourceLocation) 259 | } 260 | } 261 | 262 | func given(title: String = "track title", points: [TestGPXPoint]) -> String { 263 | let pointXML = points.map { 264 | let ele = $0.elevation.flatMap { "\($0)" } ?? "" 265 | return "\(ele)" 266 | }.joined(separator: "\n") 267 | let xml = 268 | """ 269 | 270 | 271 | 272 | 273 | 274 | \(title) 275 | 1 276 | 277 | \(pointXML) 278 | 279 | 280 | 281 | """ 282 | return xml 283 | } 284 | 285 | func expectedElevation(start: TestGPXPoint, end: TestGPXPoint, distanceFromStart: Double) -> Double? { 286 | guard let startElevation = start.elevation, let endElevation = end.elevation else { return nil } 287 | let deltaHeight = endElevation - startElevation 288 | return startElevation + deltaHeight * (distanceFromStart / start.distance(to: end)) 289 | } 290 | 291 | extension Collection { 292 | func expectedDistance() -> Double { 293 | zip(self, dropFirst()).map { 294 | $0.coordinate.distance(to: $1.coordinate) 295 | }.reduce(0, +) 296 | } 297 | } 298 | 299 | extension BinaryFloatingPoint { 300 | var mps: Measurement { 301 | .init(value: Double(self), unit: .metersPerSecond) 302 | } 303 | } 304 | 305 | extension BinaryInteger { 306 | var mps: Measurement { 307 | .init(value: Double(self), unit: .metersPerSecond) 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /Sources/GPXKit/TrackGraph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// A value describing a graph of a track. Contains metadata such as a `GPXTrack`s distance, elevation and a height-map. 8 | public struct TrackGraph: Hashable, Sendable { 9 | /// Array of ``TrackSegment`` values. The segments describe a tracks position along with its relative distance to its predecessor. 10 | public var segments: [TrackSegment] 11 | /// The overall distance of a track in meters. 12 | public var distance: Double 13 | /// The overall elevation gain of a track in meters. 14 | public var elevationGain: Double 15 | /// A heightmap, which is an array of ``DistanceHeight`` values. Each value in the heightMap has the total distance in meters up to that 16 | /// point (imagine it as the values along the x-axis in a 2D-coordinate graph) paired with the elevation in meters above sea level at 17 | /// that point (the y-value in the aforementioned 2D-graph). 18 | public var heightMap: [DistanceHeight] 19 | /// Array of `GradeSegment`s. The segments describe the grade over the entire track with specified interval length in meters in 20 | /// initializer. 21 | public var gradeSegments: [GradeSegment] = [] 22 | 23 | /// Initializes a ``TrackGraph`` 24 | /// - Parameters: 25 | /// - segments: Array of ``TrackSegment`` values. 26 | /// - distance: The graphs distance in meters. 27 | /// - elevationGain: The graphs elevation gain in meters. 28 | /// - heightMap: An array of ``DistanceHeight`` values. 29 | /// - gradeSegments: An array of ``GradeSegment`` values. 30 | public init( 31 | segments: [TrackSegment], 32 | distance: Double, 33 | elevationGain: Double, 34 | heightMap: [DistanceHeight], 35 | gradeSegments: [GradeSegment] 36 | ) { 37 | self.segments = segments 38 | self.distance = distance 39 | self.elevationGain = elevationGain 40 | self.heightMap = heightMap 41 | self.gradeSegments = gradeSegments 42 | } 43 | 44 | /// Initialize for creating a `TrackGraph` from `TrackPoint`s. 45 | /// - Parameters: 46 | /// - points: Array of `TrackPoint` values. 47 | /// - gradeSegmentLength: The length of the grade segments in meters. Defaults to 50 meters. Adjacent segments with the same grade 48 | /// will be joined together. 49 | public init(points: [TrackPoint], elevationSmoothing: ElevationSmoothing = .segmentation(50)) throws { 50 | self.init(coordinates: points, elevationSmoothing: elevationSmoothing) 51 | } 52 | 53 | /// Initializer 54 | /// You don't need to construct this value by yourself, as it is done by GXPKits track parsing logic. 55 | /// - Parameters: 56 | /// - segments: An array of ``TrackSegment``s. 57 | /// - distance: The total distance in meters. 58 | /// - elevationGain: The total elevation gain. 59 | /// - heightMap: The height-map 60 | @available(*, deprecated, message: "Will be removed in a future release, don't use it anymore!") 61 | public init(segments: [TrackSegment], distance: Double, elevationGain: Double, heightMap: [DistanceHeight]) { 62 | self.segments = segments 63 | self.distance = distance 64 | self.elevationGain = elevationGain 65 | self.heightMap = heightMap 66 | if let segment = GradeSegment( 67 | start: 0, 68 | end: distance, 69 | elevationAtStart: segments.first?.coordinate.elevation ?? 0, 70 | elevationAtEnd: segments.last?.coordinate.elevation ?? 0 71 | ) { 72 | gradeSegments = [segment] 73 | } else { 74 | gradeSegments = [] 75 | } 76 | } 77 | } 78 | 79 | public extension TrackGraph { 80 | /// The elevation at a given distance. Elevations between coordinates will be interpolated from their adjacent track corrdinates. 81 | /// - Parameter distanceInMeters: The distance from the start of the track in meters. Must be in the range **{0, trackdistance}**. 82 | /// - Returns: The elevation in meters for a given distance or nil, if ```distanceInMeters``` is not within the tracks length. 83 | func elevation(at distanceInMeters: Double) -> Double? { 84 | heightMap.height(at: distanceInMeters) 85 | } 86 | } 87 | 88 | public extension TrackGraph { 89 | /// Convenience initialize for creating a ``TrackGraph`` from ``Coordinate``s. 90 | /// - Parameter coords: Array of ``Coordinate`` values. 91 | /// - Parameter elevationSmoothing: The ``ElevationSmoothing`` for calculating the elevation grades.. 92 | init(coords: [Coordinate], elevationSmoothing: ElevationSmoothing) throws { 93 | switch elevationSmoothing { 94 | case let .combined(smoothingSampleCount, maxGradeDelta): 95 | try self.init(coords: coords, smoothingSampleCount: smoothingSampleCount, allowedGradeDelta: maxGradeDelta) 96 | case .segmentation, .smoothing, .none: 97 | let graph = TrackGraph(coordinates: coords, elevationSmoothing: elevationSmoothing) 98 | let gradeSegments = try graph.heightMap.calculateGradeSegments(elevationSmoothing) 99 | self.init( 100 | segments: graph.segments, 101 | distance: graph.distance, 102 | elevationGain: graph.elevationGain, 103 | heightMap: graph.heightMap, 104 | gradeSegments: gradeSegments 105 | ) 106 | } 107 | } 108 | 109 | init(coords: [Coordinate]) { 110 | self.init(coordinates: coords, elevationSmoothing: .none) 111 | } 112 | 113 | internal init(coordinates: [C], elevationSmoothing: ElevationSmoothing) { 114 | let segments = coordinates.trackSegments() 115 | distance = segments.calculateDistance() 116 | self.segments = segments 117 | elevationGain = coordinates.calculateElevationGain() 118 | let heightmap = segments.reduce(into: [DistanceHeight]()) { acc, segment in 119 | let distanceSoFar = (acc.last?.distance ?? 0) + segment.distanceInMeters 120 | acc.append(DistanceHeight(distance: distanceSoFar, elevation: segment.coordinate.elevation)) 121 | } 122 | heightMap = heightmap 123 | let gradeSegments = try? heightmap.calculateGradeSegments(elevationSmoothing) 124 | self.gradeSegments = gradeSegments ?? heightmap.gradeSegments() 125 | } 126 | 127 | /// Initializer for adjusting the heightmap. 128 | /// - Parameters: 129 | /// - coords: An array ``Coordinate`` values. Will be smoothed with the smoothingSampleCount parameter. 130 | /// - smoothingSampleCount: Number of neighbouring ``Coordinate`` values to take into account for smoothed elevation. 131 | /// - allowedGradeDelta: The maximum allowed grade between adjacent ``GradeSegment`` values. In normalized range [0,1]. 132 | init(coords: [Coordinate], smoothingSampleCount: Int, allowedGradeDelta: Double) throws { 133 | let graph = TrackGraph(coordinates: coords.smoothedElevation(sampleCount: smoothingSampleCount), elevationSmoothing: .none) 134 | let gradeSegments = try graph.gradeSegments.flatten(maxDelta: allowedGradeDelta) 135 | self.init( 136 | segments: graph.segments, 137 | distance: graph.distance, 138 | elevationGain: graph.elevationGain, 139 | heightMap: graph.heightMap, 140 | gradeSegments: gradeSegments 141 | ) 142 | } 143 | } 144 | 145 | public extension TrackGraph { 146 | /// Calculates the ``TrackGraph``s climbs. 147 | /// - Parameters: 148 | /// - epsilon: The simplification factor in meters for smoothing out elevation jumps. Defaults to 1. 149 | /// - minimumGrade: The minimum allowed grade in percent in the Range {0,1}. Defaults to 0.03 (3%). 150 | /// - maxJoinDistance:The maximum allowed distance between climb segments in meters. If ``Climb`` segments are closer they will get 151 | /// joined to one climb. Defaults to 0. 152 | /// - Returns: An array of ``Climb`` values. Returns an empty array if no climbs where found. 153 | func climbs(epsilon: Double = 1, minimumGrade: Double = 0.03, maxJoinDistance: Double = 0) -> [Climb] { 154 | guard 155 | heightMap.count > 1 156 | else { 157 | return [] 158 | } 159 | return findClimbs(epsilon: epsilon, minimumGrade: minimumGrade, maxJoinDistance: maxJoinDistance) 160 | } 161 | } 162 | 163 | private extension Collection where Element: HeightMappable { 164 | /// Calculates the elevation gain by applying a threshold to reduce vertical noise. 165 | /// 166 | /// See https://www.gpsvisualizer.com/tutorials/elevation_gain.html for more details 167 | /// - Parameters: 168 | /// - coordinates: An array of ``GeoCoordinate`` values for which the elevation gain should be calculated 169 | /// - threshold: The elevation threshold in meters to be applied. 170 | /// - Returns: The elevation gain 171 | func calculateElevationGain(threshold: Double = 5) -> Double { 172 | guard count > 1, let first = first else { return 0 } 173 | 174 | var reducedElevations: [Double] = [first.elevation] 175 | var lastCoord = first 176 | 177 | for coordinate in dropFirst() where abs(coordinate.elevation - lastCoord.elevation) > threshold { 178 | reducedElevations.append(coordinate.elevation) 179 | lastCoord = coordinate 180 | } 181 | 182 | let zippedCoords = zip(reducedElevations, reducedElevations.dropFirst()) 183 | return zippedCoords.reduce(0.0) { elevation, pair in 184 | let delta = pair.1 - pair.0 185 | if delta > 0 { 186 | return elevation + delta 187 | } 188 | return elevation 189 | } 190 | } 191 | } 192 | 193 | private extension Array where Element == DistanceHeight { 194 | func calculateGradeSegments(_ segmentation: ElevationSmoothing) throws -> [GradeSegment] { 195 | switch segmentation { 196 | case .none: 197 | return gradeSegments() 198 | case let .segmentation(length): 199 | return calculateGradeSegments(segmentLength: length) 200 | case let .smoothing(smoothingValue): 201 | return calculateGradeSegments(smoothingValue) 202 | case .combined(smoothingSampleCount: _, maxGradeDelta: let maxGradeDelta): 203 | return try gradeSegments().flatten(maxDelta: maxGradeDelta) 204 | } 205 | } 206 | 207 | func calculateGradeSegments(segmentLength: Double) -> [GradeSegment] { 208 | guard count > 1 else { return [] } 209 | 210 | let trackDistance = self[endIndex - 1].distance 211 | guard trackDistance >= segmentLength else { 212 | if let prevHeight = height(at: 0), 213 | let currentHeight = height(at: trackDistance), 214 | let segment = GradeSegment(start: 0, end: trackDistance, elevationAtStart: prevHeight, elevationAtEnd: currentHeight) 215 | { 216 | return [segment] 217 | } 218 | return [] 219 | } 220 | var gradeSegments: [GradeSegment] = [] 221 | var previousHeight: Double = self[0].elevation 222 | for distance in stride(from: segmentLength, to: trackDistance, by: segmentLength) { 223 | guard let height = height(at: distance) else { break } 224 | if let segment = GradeSegment( 225 | start: distance - segmentLength, 226 | end: distance, 227 | elevationAtStart: previousHeight, 228 | elevationAtEnd: height 229 | ) { 230 | gradeSegments.append(segment) 231 | } 232 | previousHeight = height 233 | } 234 | if let last = gradeSegments.last, 235 | last.end < trackDistance 236 | { 237 | if let prevHeight = height(at: last.end), 238 | let currentHeight = height(at: trackDistance), 239 | let segment = GradeSegment(start: last.end, end: trackDistance, elevationAtStart: prevHeight, elevationAtEnd: currentHeight) 240 | { 241 | gradeSegments.append(segment) 242 | } 243 | } 244 | return gradeSegments.reduce(into: []) { joined, segment in 245 | guard let last = joined.last else { 246 | joined.append(segment) 247 | return 248 | } 249 | if (last.grade - segment.grade).magnitude > 0.003 { 250 | joined.append(segment) 251 | } else { 252 | let remaining = Swift.min(segmentLength, trackDistance - last.end) 253 | joined[joined.count - 1].end += remaining 254 | joined[joined.count - 1].elevationAtEnd = segment.elevationAtEnd 255 | } 256 | } 257 | } 258 | 259 | func calculateGradeSegments(_ smoothingValue: Int) -> [GradeSegment] { 260 | guard !isEmpty else { return [] } 261 | 262 | var updateHeightMap: [DistanceHeight] = [] 263 | for idx in indices { 264 | let start = (idx - smoothingValue / 2) 265 | let end = idx + smoothingValue / 2 266 | let r = (start ... end).clamped(to: startIndex ... (endIndex - 1)) 267 | var elevation = self[r].reduce(Double(self[idx].elevation)) { ele, distanceHeight in 268 | ele + distanceHeight.elevation 269 | } 270 | elevation /= Double(r.count) 271 | 272 | updateHeightMap.append(.init(distance: self[idx].distance, elevation: elevation)) 273 | } 274 | let result = zip(updateHeightMap, updateHeightMap.dropFirst()).compactMap { cur, next in 275 | GradeSegment( 276 | start: cur.distance, 277 | end: next.distance, 278 | grade: (next.elevation - cur.elevation) / (next.distance - cur.distance), 279 | elevationAtStart: cur.elevation 280 | ) 281 | } 282 | return result 283 | } 284 | 285 | func height(at distance: Double) -> Double? { 286 | if distance == 0 { 287 | return first?.elevation 288 | } 289 | if distance == last?.distance { 290 | return last?.elevation 291 | } 292 | guard let next = firstIndex(where: { element in 293 | element.distance > distance 294 | }), next > 0 else { return nil } 295 | 296 | let start = next - 1 297 | let delta = self[next].distance - self[start].distance 298 | let t = (distance - self[start].distance) / delta 299 | return linearInterpolated(start: self[start].elevation, end: self[next].elevation, using: t) 300 | } 301 | 302 | func linearInterpolated(start: Value, end: Value, using t: Value) -> Value { 303 | start + t * (end - start) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /Sources/GPXKit/GPXFileParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Algorithms 6 | import Foundation 7 | #if canImport(FoundationXML) 8 | import FoundationXML 9 | #endif 10 | 11 | /// Error describing export errors 12 | public enum GPXParserError: Error, Equatable { 13 | /// The provided xml contains no valid GPX. 14 | case invalidGPX 15 | // No tracks where found in the provided GPX xml. 16 | case noTracksFound 17 | /// The provided xml could not be parsed. Contains the underlying NSError from the XMLParser along with the xml 18 | /// files line number where the error occurred. 19 | case parseError(_ code: Int, _ line: Int) 20 | /// The elevation smoothing failed. See ``ElevationSmoothing`` for details. 21 | case smoothingError 22 | } 23 | 24 | enum GPXTags: String { 25 | case gpx 26 | case metadata 27 | case waypoint = "wpt" 28 | case time 29 | case track = "trk" 30 | case route = "rte" 31 | case routePoint = "rtept" 32 | case name 33 | case trackPoint = "trkpt" 34 | case trackSegment = "trkseg" 35 | case elevation = "ele" 36 | case extensions 37 | case power 38 | case description = "desc" 39 | case keywords 40 | case comment = "cmt" 41 | case trackPointExtension = "trackpointextension" 42 | case temperature = "atemp" 43 | case heartrate = "hr" 44 | case cadence = "cad" 45 | case speed 46 | case type 47 | } 48 | 49 | enum GPXAttributes: String { 50 | case latitude = "lat" 51 | case longitude = "lon" 52 | } 53 | 54 | /// Struct for importing a GPX xml to an ``GPXTrack`` value. 55 | public struct GPXFileParser: Sendable { 56 | private let xml: String 57 | 58 | /// Initializer 59 | /// - Parameter xmlString: The GPX xml string. See [GPX specification for 60 | /// details](https://www.topografix.com/gpx.asp). 61 | public init(xmlString: String) { 62 | xml = xmlString 63 | } 64 | 65 | /// Parses the GPX xml. 66 | /// - Returns: A ``Result`` of the ``GPXTrack`` in the success or an ``GPXParserError`` in the failure case. 67 | /// - Parameter elevationSmoothing: The ``ElevationSmoothing`` in meters for the grade segments. Defaults to 68 | /// ``ElevationSmoothing/none``. 69 | public func parse(elevationSmoothing: ElevationSmoothing = .none) -> Result { 70 | let parser = BasicXMLParser(xml: xml) 71 | switch parser.parse() { 72 | case let .success(root): 73 | do { 74 | let track = try parseRoot(node: root, elevationSmoothing: elevationSmoothing) 75 | return .success(track) 76 | } catch { 77 | return .failure(.smoothingError) 78 | } 79 | case let .failure(error): 80 | switch error { 81 | case .noContent: 82 | return .failure(.invalidGPX) 83 | case let .parseError(error, lineNumber): 84 | return .failure(.parseError(error.code, lineNumber)) 85 | } 86 | } 87 | } 88 | 89 | private func parseRoot(node: XMLNode, elevationSmoothing: ElevationSmoothing) throws -> GPXTrack { 90 | guard let trackNode = node.childFor(.track) ?? node.childFor(.route) else { 91 | return try GPXTrack( 92 | date: node.childFor(.metadata)?.childFor(.time)?.date, 93 | waypoints: parseWaypoints(node.childrenOfType(.waypoint)), 94 | title: "", 95 | description: nil, 96 | trackPoints: [], 97 | keywords: parseKeywords(node: node), 98 | elevationSmoothing: elevationSmoothing, 99 | type: nil 100 | ) 101 | } 102 | let title = trackNode.childFor(.name)?.content ?? "" 103 | let isRoute = trackNode.name == GPXTags.route.rawValue 104 | let (trackPoints, segments) = isRoute ? parseRoute(trackNode) : parseSegment(trackNode.childrenOfType(.trackSegment)) 105 | return try GPXTrack( 106 | date: node.childFor(.metadata)?.childFor(.time)?.date, 107 | waypoints: parseWaypoints(node.childrenOfType(.waypoint)), 108 | title: title, 109 | description: trackNode.childFor(.description)?.content, 110 | trackPoints: trackPoints, 111 | keywords: parseKeywords(node: node), 112 | elevationSmoothing: elevationSmoothing, 113 | segments: segments, 114 | type: trackNode.childFor(.type)?.content 115 | ) 116 | } 117 | 118 | private func parseWaypoints(_ nodes: [XMLNode]) -> [Waypoint]? { 119 | guard !nodes.isEmpty else { return nil } 120 | return nodes.compactMap { Waypoint($0) ?? nil } 121 | } 122 | 123 | private func parseKeywords(node: XMLNode) -> [String] { 124 | node.childFor(.metadata)? 125 | .childFor(.keywords)? 126 | .content.components(separatedBy: .whitespacesAndNewlines) 127 | .filter { !$0.isEmpty } ?? [] 128 | } 129 | 130 | private func parseMetaData(_ node: XMLNode) -> Date? { 131 | return node.childFor(.time)?.date 132 | } 133 | 134 | private func parseSegment(_ segmentNodes: [XMLNode]) -> ([TrackPoint], [GPXTrack.Segment]?) { 135 | guard !segmentNodes.isEmpty else { return ([], nil) } 136 | 137 | let segmented = segmentNodes.map { 138 | $0.childrenOfType(.trackPoint).compactMap(TrackPoint.init) 139 | } 140 | var trackPoints = segmented.flatMap { $0 } 141 | let segments = segmented.reduce(into: [GPXTrack.Segment]()) { acc, segNode in 142 | guard let last = acc.last else { 143 | acc.append(.init(range: 0 ..< segNode.count, distance: segNode.totalDistance)) 144 | return 145 | } 146 | let start = last.range.count 147 | acc.append(.init(range: start ..< start + segNode.count, distance: segNode.totalDistance)) 148 | } 149 | checkForInvalidElevationAtStartAndEnd(trackPoints: &trackPoints) 150 | return ( 151 | correctElevationGaps(trackPoints: trackPoints) 152 | .map { 153 | .init( 154 | coordinate: .init( 155 | latitude: $0.latitude, 156 | longitude: $0.longitude, 157 | elevation: $0.coordinate.elevation == .greatestFiniteMagnitude ? 0 : $0.coordinate 158 | .elevation 159 | ), 160 | date: $0.date, 161 | power: $0.power, 162 | cadence: $0.cadence, 163 | heartrate: $0.heartrate, 164 | temperature: $0.temperature, 165 | speed: $0.speed 166 | ) 167 | }, 168 | segments 169 | ) 170 | } 171 | 172 | private func parseRoute(_ routeNode: XMLNode?) -> ([TrackPoint], [GPXTrack.Segment]?) { 173 | guard let node = routeNode else { 174 | return ([], nil) 175 | } 176 | var trackPoints = node.childrenOfType(.routePoint).compactMap(TrackPoint.init) 177 | checkForInvalidElevationAtStartAndEnd(trackPoints: &trackPoints) 178 | return ( 179 | correctElevationGaps(trackPoints: trackPoints) 180 | .map { 181 | .init( 182 | coordinate: .init( 183 | latitude: $0.latitude, 184 | longitude: $0.longitude, 185 | elevation: $0.coordinate.elevation == .greatestFiniteMagnitude ? 0 : $0.coordinate 186 | .elevation 187 | ), 188 | date: $0.date, 189 | power: $0.power, 190 | cadence: $0.cadence, 191 | heartrate: $0.heartrate, 192 | temperature: $0.temperature, 193 | speed: $0.speed 194 | ) 195 | }, 196 | nil 197 | ) 198 | } 199 | 200 | private func checkForInvalidElevationAtStartAndEnd(trackPoints: inout [TrackPoint]) { 201 | if 202 | trackPoints.first?.coordinate.elevation == .greatestFiniteMagnitude, 203 | let firstValidElevation = trackPoints.first(where: { $0.coordinate.elevation != .greatestFiniteMagnitude })? 204 | .coordinate.elevation 205 | { 206 | trackPoints[0].coordinate.elevation = firstValidElevation 207 | } 208 | if 209 | trackPoints.last?.coordinate.elevation == .greatestFiniteMagnitude, 210 | let lastValidElevation = trackPoints.last(where: { $0.coordinate.elevation != .greatestFiniteMagnitude })? 211 | .coordinate.elevation 212 | { 213 | trackPoints[trackPoints.count - 1].coordinate.elevation = lastValidElevation 214 | } 215 | } 216 | 217 | private func correctElevationGaps(trackPoints: [TrackPoint]) -> [TrackPoint] { 218 | struct Grade { 219 | var start: Coordinate 220 | var grade: Double 221 | } 222 | 223 | let chunks = trackPoints.chunked(on: { $0.coordinate.elevation == .greatestFiniteMagnitude }) 224 | let grades: [Grade] = chunks.filter { 225 | $0.0 == false 226 | }.adjacentPairs().compactMap { seq1, seq2 in 227 | guard 228 | let start = seq1.1.last, 229 | let end = seq2.1.first 230 | else { 231 | return nil 232 | } 233 | let dist = start.coordinate.distance(to: end.coordinate) 234 | let elevationDelta = end.coordinate.elevation - start.coordinate.elevation 235 | return Grade(start: start.coordinate, grade: elevationDelta / dist) 236 | } 237 | var corrected: [[TrackPoint]] = zip(chunks.filter(\.0), grades).map { chunk, grade in 238 | chunk.1.map { 239 | TrackPoint( 240 | coordinate: .init( 241 | latitude: $0.latitude, 242 | longitude: $0.longitude, 243 | elevation: grade.start.elevation + grade.start.distance(to: $0.coordinate) * grade.grade 244 | ), 245 | date: $0.date, 246 | power: $0.power, 247 | cadence: $0.cadence, 248 | heartrate: $0.heartrate, 249 | temperature: $0.temperature 250 | ) 251 | } 252 | } 253 | 254 | var result: [TrackPoint] = [] 255 | for chunk in chunks { 256 | if !corrected.isEmpty, chunk.0 { 257 | result.append(contentsOf: corrected.removeFirst()) 258 | } else { 259 | result.append(contentsOf: chunk.1) 260 | } 261 | } 262 | return result 263 | } 264 | } 265 | 266 | extension Waypoint { 267 | init?(_ waypointNode: XMLNode) { 268 | guard 269 | let lat = waypointNode.latitude, 270 | let lon = waypointNode.longitude 271 | else { 272 | return nil 273 | } 274 | let elevation = waypointNode.childFor(.elevation)?.elevation ?? .zero 275 | coordinate = Coordinate( 276 | latitude: lat, 277 | longitude: lon, 278 | elevation: elevation 279 | ) 280 | date = waypointNode.childFor(.time)?.date 281 | name = waypointNode.childFor(.name)?.content 282 | comment = waypointNode.childFor(.comment)?.content 283 | description = waypointNode.childFor(.description)?.content 284 | } 285 | } 286 | 287 | extension TrackPoint { 288 | init?(trackNode: XMLNode) { 289 | guard 290 | let lat = trackNode.latitude, 291 | let lon = trackNode.longitude 292 | else { 293 | return nil 294 | } 295 | coordinate = Coordinate( 296 | latitude: lat, 297 | longitude: lon, 298 | elevation: trackNode.childFor(.elevation)?.elevation ?? .greatestFiniteMagnitude 299 | ) 300 | date = trackNode.childFor(.time)?.date 301 | power = trackNode.childFor(.extensions)?.childFor(.power)?.power 302 | cadence = trackNode.childFor(.extensions)?.childFor(.trackPointExtension)?.childFor(.cadence) 303 | .flatMap { UInt($0.content) } 304 | heartrate = trackNode.childFor(.extensions)?.childFor(.trackPointExtension)?.childFor(.heartrate) 305 | .flatMap { UInt($0.content) } 306 | temperature = trackNode.childFor(.extensions)?.childFor(.trackPointExtension)?.childFor(.temperature)? 307 | .temperature 308 | speed = trackNode.childFor(.extensions)?.childFor(.trackPointExtension)?.childFor(.speed)?.speed 309 | } 310 | } 311 | 312 | extension Collection { 313 | var totalDistance: Double { 314 | zip(self, dropFirst()).map { 315 | $0.calculateDistance(to: $1) 316 | }.reduce(0, +) 317 | } 318 | } 319 | 320 | extension XMLNode { 321 | var latitude: Double? { 322 | Double(attributes[GPXAttributes.latitude.rawValue] ?? "") 323 | } 324 | 325 | var longitude: Double? { 326 | Double(attributes[GPXAttributes.longitude.rawValue] ?? "") 327 | } 328 | 329 | var elevation: Double? { 330 | Double(content) 331 | } 332 | 333 | var date: Date? { 334 | if let date = ISO8601DateFormatter.importing.date(from: content) { 335 | return date 336 | } else { 337 | return ISO8601DateFormatter.importingFractionalSeconds.date(from: content) 338 | } 339 | } 340 | 341 | var power: Measurement? { 342 | Double(content).flatMap { 343 | Measurement(value: $0, unit: .watts) 344 | } 345 | } 346 | 347 | var temperature: Measurement? { 348 | Double(content).flatMap { 349 | Measurement(value: $0, unit: .celsius) 350 | } 351 | } 352 | 353 | var speed: Measurement? { 354 | Double(content).flatMap { 355 | Measurement(value: $0, unit: .metersPerSecond) 356 | } 357 | } 358 | 359 | func childFor(_ tag: GPXTags) -> XMLNode? { 360 | children.first(where: { 361 | $0.name.lowercased() == tag.rawValue 362 | }) 363 | } 364 | 365 | func childrenOfType(_ tag: GPXTags) -> [XMLNode] { 366 | children.filter { 367 | $0.name.lowercased() == tag.rawValue 368 | } 369 | } 370 | } 371 | 372 | public extension GPXFileParser { 373 | /// Convenience initialize for loading a GPX file from an url. Fails if the track cannot be parsed. 374 | /// - Parameter url: The url containing the GPX file. See [GPX specification for 375 | /// details](https://www.topografix.com/gpx.asp). 376 | /// - Returns: An `GPXFileParser` instance or nil if the track cannot be parsed. 377 | init?(url: URL) { 378 | guard let xmlString = try? String(contentsOf: url) else { return nil } 379 | self.init(xmlString: xmlString) 380 | } 381 | 382 | /// Convenience initialize for loading a GPX file from a data. Returns nil if the track cannot be parsed. 383 | /// - Parameter data: Data containing the GPX file as encoded xml string. See [GPX specification for 384 | /// details](https://www.topografix.com/gpx.asp). 385 | /// - Returns: An `GPXFileParser` instance or nil if the track cannot be parsed. 386 | init?(data: Data) { 387 | guard let xmlString = String(data: data, encoding: .utf8) else { return nil } 388 | self.init(xmlString: xmlString) 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/GPXExporterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Testing 7 | #if canImport(FoundationXML) 8 | import FoundationXML 9 | #endif 10 | @testable import GPXKit 11 | 12 | @Suite 13 | struct GPXExporterTests { 14 | private var iso8601Formatter: ISO8601DateFormatter = { 15 | let formatter = ISO8601DateFormatter() 16 | if #available(tvOS 11.0, *) { 17 | formatter.formatOptions = .withFractionalSeconds 18 | } else { 19 | formatter.formatOptions = .withInternetDateTime 20 | } 21 | return formatter 22 | }() 23 | 24 | private func expectedHeaderAttributes(creator: String = "GPXKit") -> [String: String] { 25 | [ 26 | "creator": creator, 27 | "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", 28 | "xsi:schemaLocation": "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd", 29 | "version": "1.1", 30 | "xmlns": "http://www.topografix.com/GPX/1/1", 31 | "xmlns:gpxtpx": "http://www.garmin.com/xmlschemas/TrackPointExtension/v1", 32 | "xmlns:gpxx": "http://www.garmin.com/xmlschemas/GpxExtensions/v3" 33 | ] 34 | } 35 | 36 | func parseResult(_ xmlString: String) throws -> GPXKit.XMLNode { 37 | try BasicXMLParser(xml: xmlString).parse().get() 38 | } 39 | 40 | // MARK: Tests 41 | 42 | @Test 43 | func testExportingAnEmptyTrackWithDateAndTitleResultsInAnEmptyGPXFile() throws { 44 | let date = Date() 45 | let track = GPXTrack(date: date, title: "Track title", description: "Track description", trackPoints: [], type: "cycling") 46 | let sut = GPXExporter(track: track) 47 | 48 | let expectedContent: GPXKit.XMLNode = XMLNode( 49 | name: GPXTags.gpx.rawValue, 50 | attributes: expectedHeaderAttributes(), 51 | children: [ 52 | XMLNode(name: GPXTags.metadata.rawValue, children: [ 53 | XMLNode(name: GPXTags.time.rawValue, content: expectedString(for: date)) 54 | ]), 55 | XMLNode(name: GPXTags.track.rawValue, children: [ 56 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 57 | XMLNode(name: GPXTags.description.rawValue, content: "Track description"), 58 | XMLNode(name: GPXTags.type.rawValue, content: "cycling") 59 | ]) 60 | ] 61 | ) 62 | 63 | let result = try parseResult(sut.xmlString) 64 | 65 | assertNodesAreEqual(expectedContent, result) 66 | } 67 | 68 | @Test 69 | func testItWillNotExportANilDescription() throws { 70 | let date = Date() 71 | let track = GPXTrack(date: date, title: "Track title", description: nil, trackPoints: [], type: nil) 72 | let sut = GPXExporter(track: track) 73 | 74 | let expectedContent: GPXKit.XMLNode = XMLNode( 75 | name: GPXTags.gpx.rawValue, 76 | attributes: expectedHeaderAttributes(), 77 | children: [ 78 | XMLNode(name: GPXTags.metadata.rawValue, children: [ 79 | XMLNode(name: GPXTags.time.rawValue, content: expectedString(for: date)) 80 | ]), 81 | XMLNode(name: GPXTags.track.rawValue, children: [ 82 | XMLNode(name: GPXTags.name.rawValue, content: track.title) 83 | ]) 84 | ] 85 | ) 86 | 87 | let result = try parseResult(sut.xmlString) 88 | 89 | assertNodesAreEqual(expectedContent, result) 90 | } 91 | 92 | @Test 93 | func testExportingAnEmptyTrackWithoutDateResultsInAnEmptyGPXFileWithoutTitleAndDate() throws { 94 | let track = GPXTrack( 95 | date: nil, 96 | title: "Track title", 97 | description: "Description", 98 | trackPoints: [], 99 | keywords: ["one", "two"], 100 | type: "custom" 101 | ) 102 | let sut = GPXExporter(track: track, creatorName: "Custom creator name") 103 | 104 | let expectedContent: GPXKit.XMLNode = XMLNode( 105 | name: GPXTags.gpx.rawValue, 106 | attributes: expectedHeaderAttributes(creator: "Custom creator name"), 107 | children: [ 108 | XMLNode(name: GPXTags.metadata.rawValue, children: [ 109 | XMLNode(name: GPXTags.keywords.rawValue, content: "one two") 110 | ]), 111 | XMLNode(name: GPXTags.track.rawValue, children: [ 112 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 113 | XMLNode(name: GPXTags.description.rawValue, content: "Description"), 114 | XMLNode(name: GPXTags.type.rawValue, content: "custom") 115 | ]) 116 | ] 117 | ) 118 | 119 | let result = try parseResult(sut.xmlString) 120 | 121 | assertNodesAreEqual(expectedContent, result) 122 | } 123 | 124 | @Test 125 | func testExportingANonEmptyTrackWithDatesAndType() throws { 126 | let date = Date() 127 | let track = GPXTrack( 128 | date: date, 129 | title: "Track title", 130 | description: "Non empty track", 131 | trackPoints: givenTrackPoints(10), 132 | keywords: ["keyword1", "keyword2", "keyword3"], 133 | type: "cycling" 134 | ) 135 | let sut = GPXExporter(track: track) 136 | 137 | let result = try parseResult(sut.xmlString) 138 | 139 | let expectedContent: GPXKit.XMLNode = XMLNode( 140 | name: GPXTags.gpx.rawValue, 141 | attributes: expectedHeaderAttributes(), 142 | children: [ 143 | XMLNode(name: GPXTags.metadata.rawValue, children: [ 144 | XMLNode(name: GPXTags.time.rawValue, content: expectedString(for: date)), 145 | XMLNode(name: GPXTags.keywords.rawValue, content: "keyword1 keyword2 keyword3") 146 | ]), 147 | XMLNode(name: GPXTags.track.rawValue, children: [ 148 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 149 | XMLNode(name: GPXTags.description.rawValue, content: "Non empty track"), 150 | XMLNode(name: GPXTags.type.rawValue, content: "cycling"), 151 | XMLNode( 152 | name: GPXTags.trackSegment.rawValue, 153 | children: track.trackPoints.map { 154 | $0.expectedXMLNode(withDate: true) 155 | } 156 | ) 157 | ]) 158 | ] 159 | ) 160 | 161 | assertNodesAreEqual(expectedContent, result) 162 | } 163 | 164 | @Test 165 | func testExportingACompleteTrack() throws { 166 | let sut = GPXExporter(track: testTrackWithoutTime) 167 | 168 | let parser = GPXFileParser(xmlString: sut.xmlString) 169 | let result = try parser.parse().get() 170 | assertTracksAreEqual(testTrackWithoutTime, result) 171 | } 172 | 173 | @Test 174 | func testItWillNotExportTheDatesFromTrack() throws { 175 | let track = GPXTrack( 176 | date: Date(), 177 | title: "test track", 178 | trackPoints: [ 179 | TrackPoint(coordinate: .random, date: Date()), 180 | TrackPoint(coordinate: .random, date: Date()), 181 | TrackPoint(coordinate: .random, date: Date()), 182 | TrackPoint(coordinate: .random, date: Date()) 183 | ], 184 | type: "running" 185 | ) 186 | let sut = GPXExporter(track: track, shouldExportDate: false) 187 | let expectedContent: GPXKit.XMLNode = XMLNode( 188 | name: GPXTags.gpx.rawValue, 189 | attributes: expectedHeaderAttributes(), 190 | children: [ 191 | XMLNode(name: GPXTags.metadata.rawValue), 192 | XMLNode(name: GPXTags.track.rawValue, children: [ 193 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 194 | XMLNode(name: GPXTags.type.rawValue, content: "running"), 195 | XMLNode( 196 | name: GPXTags.trackSegment.rawValue, 197 | children: track.trackPoints.map { 198 | $0.expectedXMLNode(withDate: false) 199 | } 200 | ) 201 | ]) 202 | ] 203 | ) 204 | 205 | let result = try parseResult(sut.xmlString) 206 | 207 | assertNodesAreEqual(expectedContent, result) 208 | } 209 | 210 | @Test 211 | func testItWillNotExportNilWaypoints() throws { 212 | let track = GPXTrack(date: Date(), waypoints: nil, title: "Track title", trackPoints: [], type: nil) 213 | let sut = GPXExporter(track: track, shouldExportDate: false) 214 | let expectedContent: GPXKit.XMLNode = XMLNode( 215 | name: GPXTags.gpx.rawValue, 216 | attributes: expectedHeaderAttributes(), 217 | children: [ 218 | XMLNode(name: GPXTags.metadata.rawValue), 219 | XMLNode(name: GPXTags.track.rawValue, children: [ 220 | XMLNode(name: GPXTags.name.rawValue, content: track.title) 221 | ]) 222 | ] 223 | ) 224 | 225 | let result = try parseResult(sut.xmlString) 226 | 227 | assertNodesAreEqual(expectedContent, result) 228 | } 229 | 230 | @Test 231 | func testItWillExportAllWaypoints() throws { 232 | let waypoints = [ 233 | Waypoint(coordinate: .dehner, name: "Dehner", comment: "Dehner comment", description: "Dehner description"), 234 | Waypoint( 235 | coordinate: .kreisel, 236 | name: "Kreisel", 237 | comment: "Kreisel comment", 238 | description: "Kreisel description" 239 | ) 240 | ] 241 | let track = GPXTrack(date: Date(), waypoints: waypoints, title: "Track title", trackPoints: [], type: "track type") 242 | let sut = GPXExporter(track: track, shouldExportDate: false) 243 | let expectedContent: GPXKit.XMLNode = XMLNode( 244 | name: GPXTags.gpx.rawValue, 245 | attributes: expectedHeaderAttributes(), 246 | children: [ 247 | XMLNode(name: GPXTags.metadata.rawValue), 248 | XMLNode(name: GPXTags.waypoint.rawValue, attributes: [ 249 | GPXAttributes.latitude.rawValue: String(Coordinate.dehner.latitude), 250 | GPXAttributes.longitude.rawValue: String(Coordinate.dehner.longitude) 251 | ], children: [ 252 | XMLNode(name: GPXTags.name.rawValue, content: "Dehner"), 253 | XMLNode(name: GPXTags.comment.rawValue, content: "Dehner comment"), 254 | XMLNode(name: GPXTags.description.rawValue, content: "Dehner description") 255 | ]), 256 | XMLNode(name: GPXTags.waypoint.rawValue, attributes: [ 257 | GPXAttributes.latitude.rawValue: String(Coordinate.kreisel.latitude), 258 | GPXAttributes.longitude.rawValue: String(Coordinate.kreisel.longitude) 259 | ], children: [ 260 | XMLNode(name: GPXTags.name.rawValue, content: "Kreisel"), 261 | XMLNode(name: GPXTags.comment.rawValue, content: "Kreisel comment"), 262 | XMLNode(name: GPXTags.description.rawValue, content: "Kreisel description") 263 | ]), 264 | XMLNode(name: GPXTags.track.rawValue, children: [ 265 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 266 | XMLNode(name: GPXTags.type.rawValue, content: "track type") 267 | ]) 268 | ] 269 | ) 270 | 271 | let result = try parseResult(sut.xmlString) 272 | 273 | assertNodesAreEqual(expectedContent, result) 274 | } 275 | 276 | @Test 277 | func testExportingTrackWithSegments() throws { 278 | let date = Date() 279 | let points = [ 280 | TrackPoint( 281 | coordinate: Coordinate(latitude: 53.0736462, longitude: 13.1756965, elevation: 51.71003342), 282 | date: expectedDate(for: "2023-05-20T08:20:07Z") 283 | ), 284 | TrackPoint( 285 | coordinate: Coordinate(latitude: 53.0736242, longitude: 13.1757405, elevation: 51.7000351), 286 | date: expectedDate(for: "2023-05-20T08:20:08Z") 287 | ), 288 | TrackPoint( 289 | coordinate: Coordinate(latitude: 53.0735992, longitude: 13.1757855, elevation: 51.7000351), 290 | date: expectedDate(for: "2023-05-20T08:20:09Z") 291 | ), 292 | TrackPoint( 293 | coordinate: Coordinate(latitude: 53.0735793, longitude: 13.1758284, elevation: 51.67003632), 294 | date: expectedDate(for: "2023-05-20T08:20:10Z") 295 | ), 296 | TrackPoint( 297 | coordinate: Coordinate(latitude: 53.0735543, longitude: 13.1758994, elevation: 51.66003799), 298 | date: expectedDate(for: "2023-05-20T08:20:11Z") 299 | ), 300 | // 2nd segment 301 | TrackPoint( 302 | coordinate: Coordinate(latitude: 53.186896, longitude: 13.132096, elevation: 54.38999939), 303 | date: expectedDate(for: "2023-05-20T10:35:19Z") 304 | ), 305 | TrackPoint( 306 | coordinate: Coordinate(latitude: 53.186909, longitude: 13.132093, elevation: 54.34999847), 307 | date: expectedDate(for: "2023-05-20T10:35:20Z") 308 | ), 309 | TrackPoint( 310 | coordinate: Coordinate(latitude: 53.1869289, longitude: 13.1320901, elevation: 54.22999954), 311 | date: expectedDate(for: "2023-05-20T10:35:21Z") 312 | ), 313 | TrackPoint( 314 | coordinate: Coordinate(latitude: 53.1869399, longitude: 13.1320881, elevation: 54.16999817), 315 | date: expectedDate(for: "2023-05-20T10:35:22Z") 316 | ), 317 | TrackPoint( 318 | coordinate: Coordinate(latitude: 53.1869479, longitude: 13.1320822, elevation: 54.13999939), 319 | date: expectedDate(for: "2023-05-20T10:35:23Z") 320 | ) 321 | ] 322 | 323 | let track = GPXTrack( 324 | date: date, 325 | title: "Track title", 326 | description: "Non empty track", 327 | trackPoints: points, 328 | keywords: ["keyword1", "keyword2", "keyword3"], 329 | segments: [ 330 | .init(range: 0 ..< 5, distance: points[0 ..< 5].expectedDistance()), 331 | .init(range: 5 ..< 10, distance: points[5 ..< 10].expectedDistance()) 332 | ], 333 | type: "rowing" 334 | ) 335 | let sut = GPXExporter(track: track) 336 | 337 | let result = try parseResult(sut.xmlString) 338 | 339 | let expectedContent: GPXKit.XMLNode = XMLNode( 340 | name: GPXTags.gpx.rawValue, 341 | attributes: expectedHeaderAttributes(), 342 | children: [ 343 | XMLNode(name: GPXTags.metadata.rawValue, children: [ 344 | XMLNode(name: GPXTags.time.rawValue, content: expectedString(for: date)), 345 | XMLNode(name: GPXTags.keywords.rawValue, content: "keyword1 keyword2 keyword3") 346 | ]), 347 | XMLNode(name: GPXTags.track.rawValue, children: [ 348 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 349 | XMLNode(name: GPXTags.description.rawValue, content: "Non empty track"), 350 | XMLNode(name: GPXTags.type.rawValue, content: "rowing"), 351 | XMLNode( 352 | name: GPXTags.trackSegment.rawValue, 353 | children: points[0 ..< 5].map { 354 | $0.expectedXMLNode(withDate: true) 355 | } 356 | ), 357 | XMLNode( 358 | name: GPXTags.trackSegment.rawValue, 359 | children: points[5 ..< 10].map { 360 | $0.expectedXMLNode(withDate: true) 361 | } 362 | ) 363 | ]) 364 | ] 365 | ) 366 | 367 | assertNodesAreEqual(expectedContent, result) 368 | } 369 | 370 | @Test 371 | func testItWillNotExportAnNonexistingTrackType() throws { 372 | let date = Date() 373 | let track = GPXTrack(date: date, title: "Track title", description: "Track description", trackPoints: [], type: nil) 374 | let sut = GPXExporter(track: track) 375 | 376 | let expectedContent: GPXKit.XMLNode = XMLNode( 377 | name: GPXTags.gpx.rawValue, 378 | attributes: expectedHeaderAttributes(), 379 | children: [ 380 | XMLNode(name: GPXTags.metadata.rawValue, children: [ 381 | XMLNode(name: GPXTags.time.rawValue, content: expectedString(for: date)) 382 | ]), 383 | XMLNode(name: GPXTags.track.rawValue, children: [ 384 | XMLNode(name: GPXTags.name.rawValue, content: track.title), 385 | XMLNode(name: GPXTags.description.rawValue, content: "Track description") 386 | ]) 387 | ] 388 | ) 389 | 390 | let result = try parseResult(sut.xmlString) 391 | 392 | assertNodesAreEqual(expectedContent, result) 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/TrackGraphTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2024 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import Foundation 7 | import GPXKit 8 | import Numerics 9 | import Testing 10 | 11 | @Suite 12 | struct TrackGraphTests { 13 | let coordinates: [Coordinate] = [ 14 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 82.2), 15 | Coordinate(latitude: 51.2763700, longitude: 12.3767550, elevation: 82.2), 16 | Coordinate(latitude: 51.2764100, longitude: 12.3767400, elevation: 82.2), 17 | Coordinate(latitude: 51.2764520, longitude: 12.3767260, elevation: 82.2), 18 | Coordinate(latitude: 51.2765020, longitude: 12.3767050, elevation: 82.2) 19 | ] 20 | 21 | func givenAPoint(latitude: Double, longitude: Double, elevation: Double) -> TrackPoint { 22 | return TrackPoint( 23 | coordinate: Coordinate(latitude: latitude, longitude: longitude, elevation: elevation), 24 | date: Date() 25 | ) 26 | } 27 | 28 | func expectedDistance(from: Coordinate, to: Coordinate) -> Double { 29 | return from.distance(to: to) 30 | } 31 | 32 | func expectedGrade(for start: DistanceHeight, end: DistanceHeight) -> Double { 33 | expectedGrade(elevation: end.elevation - start.elevation, distance: end.distance - start.distance) 34 | } 35 | 36 | func expectedGrade(elevation: Double, distance: Double) -> Double { 37 | elevation / distance.magnitude 38 | } 39 | 40 | func expectedScore(start: DistanceHeight, end: DistanceHeight) -> Double { 41 | expectedScore(distance: end.distance - start.distance, height: end.elevation - start.elevation) 42 | } 43 | 44 | func expectedScore(distance: Double, height: Double) -> Double { 45 | // FIETS Score = (H * H / (D * 10)) + (T - 1000) / 1000 Note: The last part (+ (T - 1000) / 1000) will be 46 | // omitted 47 | return height * height / (distance * 10.0) 48 | } 49 | 50 | // MARK: Tests 51 | 52 | @Test 53 | func testSegmentDistances() throws { 54 | let expectedDistances = [0.0] + zip(coordinates, coordinates.dropFirst()).map { 55 | expectedDistance(from: $0, to: $1) 56 | } 57 | 58 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 59 | for (index, expectedDistance) in expectedDistances.enumerated() { 60 | #expect(sut.segments[index].distanceInMeters.isApproximatelyEqual(to: expectedDistance, absoluteTolerance: 0.001)) 61 | } 62 | } 63 | 64 | @Test 65 | func testTotalDistance() throws { 66 | let totalDistance = zip(coordinates, coordinates.dropFirst()).map { 67 | expectedDistance(from: $0, to: $1) 68 | }.reduce(0, +) 69 | 70 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 71 | #expect(totalDistance.isApproximatelyEqual(to: sut.distance, absoluteTolerance: 0.001)) 72 | } 73 | 74 | @Test 75 | func testTotalElevationWithTheSameElevationAtEveryPoint() throws { 76 | let coordinates: [Coordinate] = [ 77 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 1), 78 | Coordinate(latitude: 51.2763700, longitude: 12.3767550, elevation: 1), 79 | Coordinate(latitude: 51.2764100, longitude: 12.3767400, elevation: 1), 80 | Coordinate(latitude: 51.2764520, longitude: 12.3767260, elevation: 1), 81 | Coordinate(latitude: 51.2765020, longitude: 12.3767050, elevation: 1) 82 | ] 83 | 84 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 85 | 86 | expectNoDifference(0, sut.elevationGain) 87 | } 88 | 89 | @Test 90 | func testTotalElevationWithDifferentElevation() throws { 91 | let coordinates: [Coordinate] = [ 92 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 1), 93 | Coordinate(latitude: 51.2763700, longitude: 12.3767550, elevation: 11), // 10 94 | Coordinate(latitude: 51.2764100, longitude: 12.3767400, elevation: 5), // -6 95 | Coordinate(latitude: 51.2764520, longitude: 12.3767260, elevation: 100), // 95 96 | Coordinate(latitude: 51.2765020, longitude: 12.3767050, elevation: 76), // -24 97 | Coordinate(latitude: 51.2765520, longitude: 12.3766820, elevation: 344) // 268 98 | ] 99 | 100 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 101 | 102 | // 10 + 95 + 268 103 | expectNoDifference(373, sut.elevationGain) 104 | } 105 | 106 | @Test 107 | func testInitializationFromGPX() throws { 108 | let points: [TrackPoint] = [ 109 | givenAPoint(latitude: 51.2763320, longitude: 12.3767670, elevation: 1), 110 | givenAPoint(latitude: 51.2763700, longitude: 12.3767550, elevation: 1), 111 | givenAPoint(latitude: 51.2764100, longitude: 12.3767400, elevation: 1), 112 | givenAPoint(latitude: 51.2764520, longitude: 12.3767260, elevation: 1), 113 | givenAPoint(latitude: 51.2765020, longitude: 12.3767050, elevation: 1) 114 | ] 115 | 116 | let sut = try TrackGraph(points: points) 117 | 118 | let expectedCoordinates: [Coordinate] = [ 119 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 1), 120 | Coordinate(latitude: 51.2763700, longitude: 12.3767550, elevation: 1), 121 | Coordinate(latitude: 51.2764100, longitude: 12.3767400, elevation: 1), 122 | Coordinate(latitude: 51.2764520, longitude: 12.3767260, elevation: 1), 123 | Coordinate(latitude: 51.2765020, longitude: 12.3767050, elevation: 1) 124 | ] 125 | expectNoDifference(expectedCoordinates, sut.segments.map { $0.coordinate }) 126 | } 127 | 128 | @Test 129 | func testTheInitialElevationIsSubstractedFromTheElevationGain() throws { 130 | let coordinates: [Coordinate] = [ 131 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 100), 132 | Coordinate(latitude: 51.2763700, longitude: 12.3767550, elevation: 110), 133 | Coordinate(latitude: 51.2764100, longitude: 12.3767400, elevation: 120), 134 | Coordinate(latitude: 51.2764520, longitude: 12.3767260, elevation: 130), 135 | Coordinate(latitude: 51.2765020, longitude: 12.3767050, elevation: 140), 136 | Coordinate(latitude: 51.2765520, longitude: 12.3766820, elevation: 150) 137 | ] 138 | 139 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 140 | 141 | // 10 + 95 + 268 142 | expectNoDifference(50, sut.elevationGain) 143 | } 144 | 145 | @Test 146 | func testElevationGainIsTheSumOfAllElevationDifferences() throws { 147 | let coordinates: [Coordinate] = [ 148 | Coordinate(latitude: 51.2763320, longitude: 12.3767670, elevation: 100), 149 | Coordinate(latitude: 51.2763700, longitude: 12.3767550, elevation: 130), // 30 150 | Coordinate(latitude: 51.2764100, longitude: 12.3767400, elevation: 70), 151 | Coordinate(latitude: 51.2764520, longitude: 12.3767260, elevation: 150), // 80 152 | Coordinate(latitude: 51.2765020, longitude: 12.3767050, elevation: 140), 153 | Coordinate(latitude: 51.2765520, longitude: 12.3766820, elevation: 150) // 10 154 | ] 155 | 156 | let sut = try TrackGraph(coords: coordinates, elevationSmoothing: .segmentation(50)) 157 | 158 | expectNoDifference(30 + 80 + 10, sut.elevationGain) 159 | } 160 | 161 | @Test 162 | func testEmptyTrackGraphHasNoClimbs() throws { 163 | let sut = try TrackGraph(coords: [], elevationSmoothing: .segmentation(50)) 164 | 165 | expectNoDifference([], sut.climbs()) 166 | } 167 | 168 | @Test 169 | func testClimbsWithOnePointInTrackIsEmpty() throws { 170 | let sut = try TrackGraph(coords: [.leipzig], elevationSmoothing: .segmentation(50)) 171 | 172 | expectNoDifference([], sut.climbs()) 173 | } 174 | 175 | @Test 176 | func testATrackWithTwoPointsHasOneClimb() throws { 177 | let sut = try TrackGraph( 178 | coords: [.leipzig, .leipzig.offset(east: 1000, elevation: 50)], 179 | elevationSmoothing: .segmentation(50) 180 | ) 181 | 182 | let expected = Climb( 183 | start: sut.heightMap.first!.distance, 184 | end: sut.heightMap.last!.distance, 185 | bottom: sut.heightMap.first!.elevation, 186 | top: sut.heightMap.last!.elevation, 187 | totalElevation: sut.heightMap.last!.elevation - sut.heightMap.first!.elevation, 188 | grade: expectedGrade(for: sut.heightMap.first!, end: sut.heightMap.last!), 189 | maxGrade: expectedGrade(for: sut.heightMap.first!, end: sut.heightMap.last!), 190 | score: expectedScore( 191 | start: sut.heightMap.first!, 192 | end: sut.heightMap.last! 193 | ) 194 | ) 195 | 196 | expectNoDifference([expected], sut.climbs()) 197 | } 198 | 199 | @Test 200 | func testDownhillSectionsWillNotBeInTheClimbs() throws { 201 | let sut = try TrackGraph( 202 | coords: [.leipzig, .leipzig.offset(north: 1000, elevation: -50)], 203 | elevationSmoothing: .segmentation(50) 204 | ) 205 | 206 | expectNoDifference([], sut.climbs()) 207 | } 208 | 209 | @Test 210 | func testMultipleAdjacentClimbSegmentsWithDifferentGradesWillBeJoinedTogether() throws { 211 | let sut = try TrackGraph(coords: [ 212 | .leipzig, 213 | .leipzig.offset(distance: 2000, grade: 0.05), 214 | .leipzig.offset(distance: 3000, grade: 0.06), 215 | .leipzig.offset(distance: 5000, grade: 0.07) 216 | ], elevationSmoothing: .segmentation(50)) 217 | 218 | let expectedDistance = sut.heightMap.last!.distance 219 | let expectedTotalElevation = sut.heightMap.last!.elevation - sut.heightMap.first!.elevation 220 | let expected = Climb( 221 | start: sut.heightMap[0].distance, 222 | end: sut.heightMap.last!.distance, 223 | bottom: sut.heightMap.first!.elevation, 224 | top: sut.heightMap.last!.elevation, 225 | totalElevation: expectedTotalElevation, 226 | grade: expectedGrade(elevation: expectedTotalElevation, distance: expectedDistance), 227 | maxGrade: expectedGrade(for: sut.heightMap[2], end: sut.heightMap[3]), 228 | score: expectedScore(start: sut.heightMap[0], end: sut.heightMap[1]) + 229 | expectedScore(start: sut.heightMap[1], end: sut.heightMap[2]) + 230 | expectedScore(start: sut.heightMap[2], end: sut.heightMap[3]) 231 | ) 232 | 233 | expectNoDifference([expected], sut.climbs()) 234 | } 235 | 236 | @Test 237 | func testItJoinsAdjacentSegmentsWithTheSameGrade() throws { 238 | let sut = try TrackGraph(coords: (1 ... 10).map { 239 | .kreisel.offset(north: Double($0) * 1000, elevation: Double($0) * 100) 240 | }, elevationSmoothing: .segmentation(50)) 241 | 242 | expectNoDifference([ 243 | Climb( 244 | start: sut.heightMap.first!.distance, 245 | end: sut.heightMap.last!.distance, 246 | bottom: sut.heightMap.first!.elevation, 247 | top: sut.heightMap.last!.elevation, 248 | totalElevation: sut.heightMap.last!.elevation - sut.heightMap.first!.elevation, 249 | grade: expectedGrade(for: sut.heightMap.first!, end: sut.heightMap.last!), 250 | maxGrade: expectedGrade(for: sut.heightMap.first!, end: sut.heightMap.last!), 251 | score: expectedScore(start: sut.heightMap.first!, end: sut.heightMap.last!) 252 | ) 253 | ], sut.climbs()) 254 | } 255 | 256 | @Test 257 | func testFlatSectionsBetweenClimbsWillBeOmitted() throws { 258 | let sut = try TrackGraph(coords: [ 259 | // 1st climb 260 | .dehner, 261 | .dehner.offset(distance: 2000, grade: 0.055), 262 | // descent & flat section 263 | .dehner.offset(east: 2100, elevation: 70), 264 | .dehner.offset(east: 3000, elevation: 70), 265 | // 2nd climb 266 | .leipzig.offset(distance: 5000, grade: 0.055) 267 | ], elevationSmoothing: .segmentation(50)) 268 | 269 | let expectedA = Climb( 270 | start: sut.heightMap[0].distance, 271 | end: sut.heightMap[1].distance, 272 | bottom: sut.heightMap[0].elevation, 273 | top: sut.heightMap[1].elevation, 274 | totalElevation: sut.heightMap[1].elevation - sut.heightMap[0].elevation, 275 | grade: expectedGrade(for: sut.heightMap[0], end: sut.heightMap[1]), 276 | maxGrade: expectedGrade(for: sut.heightMap[0], end: sut.heightMap[1]), 277 | score: expectedScore(start: sut.heightMap[0], end: sut.heightMap[1]) 278 | ) 279 | 280 | let expectedB = Climb( 281 | start: sut.heightMap[3].distance, 282 | end: sut.heightMap[4].distance, 283 | bottom: sut.heightMap[3].elevation, 284 | top: sut.heightMap[4].elevation, 285 | totalElevation: sut.heightMap[4].elevation - sut.heightMap[3].elevation, 286 | grade: expectedGrade(for: sut.heightMap[3], end: sut.heightMap[4]), 287 | maxGrade: expectedGrade(for: sut.heightMap[3], end: sut.heightMap[4]), 288 | score: expectedScore(start: sut.heightMap[3], end: sut.heightMap[4]) 289 | ) 290 | 291 | expectNoDifference([expectedA, expectedB], sut.climbs()) 292 | } 293 | 294 | @Test 295 | func testItFiltersOutClimbsWithGradeLessThanMinimumGrade() throws { 296 | let sut = try TrackGraph(coords: [ 297 | .leipzig, 298 | .leipzig.offset(distance: 3000, grade: 0.05), 299 | .leipzig.offset(distance: 6000, grade: 0.049) 300 | ], elevationSmoothing: .segmentation(50)) 301 | 302 | let expected = Climb( 303 | start: sut.heightMap[0].distance, 304 | end: sut.heightMap[1].distance, 305 | bottom: sut.heightMap[0].elevation, 306 | top: sut.heightMap[1].elevation, 307 | totalElevation: sut.heightMap[1].elevation - sut.heightMap[0].elevation, 308 | grade: expectedGrade(for: sut.heightMap[0], end: sut.heightMap[1]), 309 | maxGrade: expectedGrade(for: sut.heightMap[0], end: sut.heightMap[1]), 310 | score: expectedScore(start: sut.heightMap[0], end: sut.heightMap[1]) 311 | ) 312 | 313 | expectNoDifference([expected], sut.climbs(minimumGrade: 0.05)) 314 | } 315 | 316 | @Test 317 | func testJoiningClimbsWithinMaxJoinDistance() throws { 318 | let sut = try TrackGraph(coords: [ 319 | .leipzig, 320 | .leipzig.offset(east: 2000, elevation: 100), 321 | .leipzig.offset(east: 2100, elevation: 80), 322 | .leipzig.offset(east: 300, elevation: 300) 323 | ], elevationSmoothing: .segmentation(50)) 324 | 325 | let expectedTotalElevation = 100.0 + (300.0 - 80.0) 326 | let expected = Climb( 327 | start: sut.heightMap[0].distance, 328 | end: sut.heightMap.last!.distance, 329 | bottom: sut.heightMap[0].elevation, 330 | top: sut.heightMap.last!.elevation, 331 | totalElevation: expectedTotalElevation, 332 | grade: expectedGrade(elevation: expectedTotalElevation, distance: sut.distance), 333 | maxGrade: expectedGrade(for: sut.heightMap[2], end: sut.heightMap[3]), 334 | score: expectedScore(start: sut.heightMap[0], end: sut.heightMap[1]) + expectedScore( 335 | start: sut.heightMap[2], 336 | end: sut.heightMap[3] 337 | ) 338 | ) 339 | 340 | expectNoDifference([expected], sut.climbs(minimumGrade: 0.05, maxJoinDistance: 200.0)) 341 | 342 | expectNoDifference([ 343 | Climb( 344 | start: sut.heightMap[0].distance, 345 | end: sut.heightMap[1].distance, 346 | bottom: sut.heightMap[0].elevation, 347 | top: sut.heightMap[1].elevation, 348 | totalElevation: sut.heightMap[1].elevation - sut.heightMap[0].elevation, 349 | grade: expectedGrade(for: sut.heightMap[0], end: sut.heightMap[1]), 350 | maxGrade: expectedGrade(for: sut.heightMap[0], end: sut.heightMap[1]), 351 | score: expectedScore(start: sut.heightMap[0], end: sut.heightMap[1]) 352 | ), 353 | Climb( 354 | start: sut.heightMap[2].distance, 355 | end: sut.heightMap[3].distance, 356 | bottom: sut.heightMap[2].elevation, 357 | top: sut.heightMap[3].elevation, 358 | totalElevation: sut.heightMap[3].elevation - sut.heightMap[2].elevation, 359 | grade: expectedGrade(for: sut.heightMap[2], end: sut.heightMap[3]), 360 | maxGrade: expectedGrade(for: sut.heightMap[2], end: sut.heightMap[3]), 361 | score: expectedScore(start: sut.heightMap[2], end: sut.heightMap[3]) 362 | ) 363 | ], sut.climbs(minimumGrade: 0.05, maxJoinDistance: 50)) 364 | } 365 | 366 | @Test 367 | func testGradeSegmentsForEmptyGraphIsEmptyArray() throws { 368 | let sut = try TrackGraph(coords: [], elevationSmoothing: .segmentation(50)) 369 | 370 | expectNoDifference([], sut.gradeSegments) 371 | } 372 | 373 | @Test 374 | func testGraphWithTheSameGrade() throws { 375 | let sut = try TrackGraph( 376 | coords: [.leipzig, .leipzig.offset(north: 1000, elevation: 100)], 377 | elevationSmoothing: .segmentation(25) 378 | ) 379 | 380 | expectNoDifference( 381 | [GradeSegment(start: 0, end: sut.distance, elevationAtStart: 0, elevationAtEnd: 100)], 382 | sut.gradeSegments 383 | ) 384 | } 385 | 386 | @Test 387 | func testGraphWithVaryingGradeHasSegmentsInTheSpecifiedLength() throws { 388 | let first: Coordinate = .leipzig.offset(distance: 100, grade: 0.1) 389 | let second: Coordinate = first.offset(distance: 100, grade: 0.2) 390 | let sut = try TrackGraph(coords: [ 391 | .leipzig, 392 | first, 393 | second 394 | ], elevationSmoothing: .segmentation(25)) 395 | 396 | let expected: [GradeSegment] = try [ 397 | #require(.init(start: 0, end: 100, grade: 0.1, elevationAtStart: 0)), 398 | #require(.init(start: 100, end: sut.distance, grade: 0.2, elevationAtStart: 10)) 399 | ] 400 | expectNoDifference(expected, sut.gradeSegments) 401 | } 402 | 403 | @Test 404 | func testGraphShorterThanSegmentDistance() throws { 405 | let sut = try TrackGraph(coords: [ 406 | .leipzig, 407 | .leipzig.offset(distance: 50, grade: 0.3) 408 | ], elevationSmoothing: .segmentation(100)) 409 | 410 | let expected: [GradeSegment] = try [ 411 | #require(GradeSegment(start: 0, end: sut.distance, elevationAtStart: 0, elevationAtEnd: 14.57)) 412 | ] 413 | expectNoDifference(expected, sut.gradeSegments) 414 | } 415 | 416 | @Test 417 | func testNegativeGrades() throws { 418 | let start = Coordinate.leipzig.offset(elevation: 100) 419 | let first: Coordinate = start.offset(distance: 100, grade: 0.1) 420 | let second: Coordinate = first.offset(distance: 100, grade: 0.2) 421 | let third: Coordinate = second.offset(distance: 100, grade: -0.3) 422 | let sut = try TrackGraph(coords: [ 423 | start, 424 | first, 425 | second, 426 | third 427 | ], elevationSmoothing: .segmentation(50)) 428 | 429 | let expected: [GradeSegment] = try [ 430 | #require(.init(start: 0, end: 100, elevationAtStart: 100, elevationAtEnd: 109.98)), 431 | #require(.init(start: 100, end: 200, elevationAtStart: 109.98, elevationAtEnd: 129.64)), 432 | #require(.init( 433 | start: 200, 434 | end: sut.distance, 435 | elevationAtStart: 129.64, 436 | elevationAtEnd: 100.56 437 | )) 438 | ] 439 | expectNoDifference(expected, sut.gradeSegments) 440 | } 441 | 442 | @Test 443 | func testGradeSegmentsWhenInitializedFromDefaultInitializer() throws { 444 | let start = Coordinate.leipzig.offset(elevation: 100) 445 | let first: Coordinate = start.offset(distance: 100, grade: 0.1) 446 | let second: Coordinate = first.offset(distance: 100, grade: 0.2) 447 | let third: Coordinate = second.offset(distance: 100, grade: -0.3) 448 | let fourth: Coordinate = third.offset(distance: 50, grade: 0.06) 449 | let sut = try TrackGraph(points: [ 450 | start, 451 | first, 452 | second, 453 | third, 454 | fourth 455 | ].map { TrackPoint(coordinate: $0) }, elevationSmoothing: .segmentation(50)) 456 | 457 | let expected: [GradeSegment] = try [ 458 | #require(.init(start: 0, end: 100, grade: 0.1, elevationAtStart: 100)), 459 | #require(.init(start: 100, end: 200, grade: 0.2, elevationAtStart: 110)), 460 | #require(.init(start: 200, end: 300, grade: -0.3, elevationAtStart: 129.64)), 461 | #require(.init( 462 | start: 300, 463 | end: sut.distance, 464 | grade: 0.06, 465 | elevationAtStart: 100.58 466 | )) 467 | ] 468 | expectNoDifference(expected, sut.gradeSegments) 469 | } 470 | 471 | @Test 472 | func testElevationAtDistanceTestBeyondTheTracksBounds() throws { 473 | let start = Coordinate.leipzig.offset(elevation: 100) 474 | let end: Coordinate = start.offset(distance: 100, grade: 0.1) 475 | let sut = try TrackGraph(coords: [ 476 | start, 477 | end 478 | ], elevationSmoothing: .segmentation(50)) 479 | 480 | #expect(sut.elevation(at: -10) == nil) 481 | #expect(sut.elevation(at: Double.greatestFiniteMagnitude) == nil) 482 | #expect(sut.elevation(at: -Double.greatestFiniteMagnitude) == nil) 483 | #expect(sut.elevation(at: sut.distance + 1) == nil) 484 | #expect(sut.elevation(at: sut.distance + 100) == nil) 485 | } 486 | 487 | @Test 488 | func testElevationAtDistanceForDistancesAtCoordinates() throws { 489 | let start = Coordinate.leipzig.offset(elevation: 100) 490 | let first = start.offset(distance: 100, grade: 0.1) 491 | let second = first.offset(distance: 100, grade: 0.2) 492 | let third = second.offset(distance: 100, grade: -0.3) 493 | let coords = [ 494 | start, 495 | first, 496 | second, 497 | third 498 | ] 499 | let sut = try TrackGraph(coords: coords, elevationSmoothing: .segmentation(50)) 500 | 501 | expectNoDifference(start.elevation, sut.elevation(at: 0)) 502 | 503 | #expect(first.elevation.isApproximatelyEqual( 504 | to: try #require(sut.elevation(at: start.distance(to: first))), 505 | absoluteTolerance: 0.001 506 | )) 507 | #expect(second.elevation.isApproximatelyEqual( 508 | to: try #require(sut.elevation(at: start.distance(to: second))), 509 | absoluteTolerance: 0.001 510 | )) 511 | #expect(third.elevation.isApproximatelyEqual( 512 | to: try #require(sut.elevation(at: start.distance(to: third))), 513 | absoluteTolerance: 0.001 514 | )) 515 | #expect(third.elevation.isApproximatelyEqual( 516 | to: try #require(sut.elevation(at: sut.distance)), 517 | absoluteTolerance: 0.001 518 | )) 519 | } 520 | 521 | @Test 522 | func testElevationAtDistanceForDistancesBetweenCoordinates() throws { 523 | let start = Coordinate.leipzig.offset(elevation: 100) 524 | let first = start.offset(distance: 100, grade: 0.1) 525 | let second = first.offset(distance: 100, grade: 0.2) 526 | let third = second.offset(distance: 100, grade: -0.3) 527 | let coords = [ 528 | start, 529 | first, 530 | second, 531 | third 532 | ] 533 | let sut = try TrackGraph(coords: coords, elevationSmoothing: .segmentation(50)) 534 | 535 | for (lhs, rhs) in sut.heightMap.adjacentPairs() { 536 | let distanceDelta = rhs.distance - lhs.distance 537 | let heightDelta = rhs.elevation - lhs.elevation 538 | for t in stride(from: 0, through: 1, by: 0.1) { 539 | let expectedHeight = lhs.elevation + t * heightDelta 540 | 541 | #expect(expectedHeight.isApproximatelyEqual( 542 | to: try #require(sut.elevation(at: lhs.distance + distanceDelta * t)), 543 | absoluteTolerance: 0.001 544 | )) 545 | } 546 | } 547 | } 548 | 549 | @Test 550 | func testInitializationFromCoordinates() throws { 551 | let start = Coordinate.leipzig.offset(elevation: 100) 552 | let first = start.offset(distance: 100, grade: 0.1) 553 | let second = first.offset(distance: 100, grade: 0.2) 554 | let third = second.offset(distance: 100, grade: -0.3) 555 | let coords = [ 556 | start, 557 | first, 558 | second, 559 | third 560 | ] 561 | let sut = TrackGraph(coords: coords) 562 | 563 | let expected = [ 564 | DistanceHeight(distance: 0, elevation: 100), 565 | DistanceHeight(distance: 99.88810211970392, elevation: 109.96686524911621), 566 | DistanceHeight(distance: 199.77620423940783, elevation: 129.70642123410428), 567 | DistanceHeight(distance: 299.6643063591117, elevation: 100.56074178631758) 568 | ] 569 | expectNoDifference(expected, sut.heightMap) 570 | expectNoDifference(29.706421234104283, sut.elevationGain) 571 | try expectNoDifference(#require(expected.last).distance, sut.distance) 572 | expectNoDifference([ 573 | GradeSegment(start: 0, end: 99.88810211970392, grade: 0.1, elevationAtStart: 100), 574 | GradeSegment(start: 99.88810211970392, end: 199.77620423940783, grade: 0.2, elevationAtStart: 110), 575 | GradeSegment( 576 | start: 199.77620423940783, 577 | end: 299.6643063591117, 578 | elevationAtStart: 129.70642123410428, 579 | elevationAtEnd: 100.56074178631758 580 | ) 581 | ], sut.gradeSegments) 582 | expectNoDifference(sut.distance, sut.gradeSegments.last?.end) 583 | } 584 | } 585 | -------------------------------------------------------------------------------- /Tests/GPXKitTests/GPXParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPXKit - MIT License - Copyright © 2025 Markus Müller. All rights reserved. 3 | // 4 | 5 | import CustomDump 6 | import Foundation 7 | import GPXKit 8 | import Testing 9 | #if canImport(FoundationXML) 10 | import FoundationXML 11 | #endif 12 | 13 | @Suite 14 | struct GPXParserTests { 15 | func parseXML(_ xml: String) throws -> GPXTrack { 16 | try GPXFileParser(xmlString: xml).parse().get() 17 | } 18 | 19 | @Test 20 | func testImportingAnEmptyGPXString() { 21 | #expect(throws: GPXParserError.parseError(1, 0)) { 22 | try GPXFileParser(xmlString: "").parse().get() 23 | } 24 | } 25 | 26 | @Test 27 | func testParsingGPXFilesWithoutATrack() throws { 28 | let sut = try parseXML(""" 29 | 30 | 31 | 32 | 33 | 34 | 35 | """) 36 | 37 | expectNoDifference("", sut.title) 38 | expectNoDifference(nil, sut.description) 39 | #expect(expectedDate(for: "2020-03-17T11:27:02Z") == sut.date) 40 | expectNoDifference([], sut.trackPoints) 41 | expectNoDifference([], sut.graph.heightMap) 42 | expectNoDifference([], sut.graph.segments) 43 | expectNoDifference([], sut.graph.climbs()) 44 | } 45 | 46 | @Test 47 | func testParsingTrackTitlesTypeAndDescription() throws { 48 | let result = try parseXML( 49 | """ 50 | 51 | 52 | 53 | 54 | 55 | 56 | Frühjahrsgeschlender ach wie schön!Track description 57 | 1 58 | 59 | 60 | """ 61 | ) 62 | 63 | expectNoDifference("Frühjahrsgeschlender ach wie schön!", result.title) 64 | expectNoDifference("Track description", result.description) 65 | expectNoDifference("1", result.type) 66 | } 67 | 68 | @Test 69 | func testParsingTrackSegmentsWithoutExtensions() throws { 70 | let result = try parseXML(testXMLWithoutExtensions) 71 | 72 | let expected = [ 73 | TrackPoint( 74 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 114.2), 75 | date: expectedDate(for: "2020-07-03T13:20:50.000Z") 76 | ), 77 | TrackPoint( 78 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 114.0), 79 | date: expectedDate(for: "2020-03-18T12:45:48Z") 80 | ) 81 | ] 82 | 83 | assertTracksAreEqual(GPXTrack( 84 | date: expectedDate(for: "2020-03-18T12:39:47Z"), 85 | title: "Haus- und Seenrunde Ausdauer", 86 | trackPoints: expected, 87 | type: "" 88 | ), result) 89 | } 90 | 91 | @Test 92 | func testParsingTrackSegmentsWithDefaultExtensions() throws { 93 | let result = try parseXML(testXMLData) 94 | 95 | let expected = [ 96 | TrackPoint( 97 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 114.2), 98 | date: expectedDate(for: "2020-03-18T12:39:47Z"), 99 | power: Measurement(value: 42, unit: .watts), 100 | cadence: 40, 101 | heartrate: 97, 102 | temperature: Measurement(value: 21, unit: .celsius), 103 | speed: Measurement(value: 1.23456, unit: .metersPerSecond) 104 | ), 105 | TrackPoint( 106 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 114.0), 107 | date: expectedDate(for: "2020-03-18T12:39:48Z"), 108 | power: Measurement(value: 272, unit: .watts), 109 | cadence: 45, 110 | heartrate: 87, 111 | temperature: Measurement(value: 20.5, unit: .celsius), 112 | speed: Measurement(value: 0.12345, unit: .metersPerSecond) 113 | ) 114 | ] 115 | 116 | assertTracksAreEqual(GPXTrack( 117 | date: expectedDate(for: "2020-03-18T12:39:47Z"), 118 | title: "Haus- und Seenrunde Ausdauer", 119 | description: "Track description", 120 | trackPoints: expected, 121 | type: "1" 122 | ), result) 123 | } 124 | 125 | @Test 126 | func testParsingTrackSegmentsWithNameSpacedExtensions() throws { 127 | let result = try parseXML(namespacedTestXMLData) 128 | 129 | let expected = [ 130 | TrackPoint( 131 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 114.2), 132 | date: expectedDate(for: "2020-03-18T12:39:47Z"), 133 | power: Measurement(value: 166, unit: .watts), 134 | cadence: 99, 135 | heartrate: 90, 136 | temperature: Measurement(value: 22, unit: .celsius), 137 | speed: Measurement(value: 1.23456, unit: .metersPerSecond) 138 | ), 139 | TrackPoint( 140 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 114.0), 141 | date: expectedDate(for: "2020-03-18T12:39:48Z"), 142 | power: Measurement(value: 230, unit: .watts), 143 | cadence: 101, 144 | heartrate: 92, 145 | temperature: Measurement(value: 21, unit: .celsius), 146 | speed: Measurement(value: 0.123456, unit: .metersPerSecond) 147 | ) 148 | ] 149 | 150 | assertTracksAreEqual(GPXTrack( 151 | date: expectedDate(for: "2020-03-18T12:39:47Z"), 152 | title: "Haus- und Seenrunde Ausdauer", 153 | description: "Track description", 154 | trackPoints: expected, 155 | type: "1" 156 | ), result) 157 | } 158 | 159 | @Test 160 | func testParsingTrackSegmentsWithoutTimeHaveANilDate() throws { 161 | let result = try parseXML(""" 162 | 163 | 164 | 165 | 166 | 167 | Haus- und Seenrunde Ausdauer 168 | cycling 169 | 170 | 171 | 114.2 172 | 173 | 174 | 114.0 175 | 176 | 177 | 178 | 179 | """) 180 | 181 | let expected = [ 182 | TrackPoint( 183 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 114.2), 184 | date: nil 185 | ), 186 | TrackPoint( 187 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 114.0), 188 | date: nil 189 | ) 190 | ] 191 | 192 | assertTracksAreEqual(GPXTrack( 193 | date: nil, 194 | title: "Haus- und Seenrunde Ausdauer", 195 | trackPoints: expected, type: "cycling" 196 | ), result) 197 | } 198 | 199 | @Test 200 | func testParsingTrackWithoutElevation() throws { 201 | let result = try parseXML(""" 202 | 203 | 204 | 205 | 206 | 207 | Haus- und Seenrunde Ausdauer 208 | running 209 | 210 | 211 | 212 | 213 | 214 | 215 | """) 216 | 217 | let expected = [ 218 | TrackPoint( 219 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 0), 220 | date: nil 221 | ), 222 | TrackPoint( 223 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 0), 224 | date: nil 225 | ) 226 | ] 227 | 228 | assertTracksAreEqual(GPXTrack( 229 | date: nil, 230 | title: "Haus- und Seenrunde Ausdauer", 231 | trackPoints: expected, type: "running" 232 | ), result) 233 | } 234 | 235 | @Test 236 | func testTrackPointsDateWithFraction() throws { 237 | let result = try parseXML(sampleGPX) 238 | 239 | let date = try #require(result.trackPoints.first?.date) 240 | 241 | expectNoDifference(1351121380, date.timeIntervalSince1970) 242 | } 243 | 244 | @Test 245 | func testTrackLength() throws { 246 | let result = try parseXML(sampleGPX) 247 | 248 | let distance = result.graph.distance 249 | let elevation = result.graph.elevationGain 250 | 251 | #expect(distance.isApproximatelyEqual(to: 3100.5625, absoluteTolerance: 10)) 252 | #expect(elevation.isApproximatelyEqual(to: 115.19, absoluteTolerance: 0.1)) 253 | } 254 | 255 | @Test 256 | func testTracksWithoutElevationInTheGPXHaveAnElevationOfZero() throws { 257 | let result = try parseXML(given(points: [.leipzig, .postPlatz, .dehner])) 258 | 259 | #expect(result.graph.elevationGain == 0) 260 | } 261 | 262 | @Test 263 | func testItInterpolatesElevationGapsWithElevationAtStartEndEndOfTheTrack() throws { 264 | // 0m: 100, 250m: nil, 500m: 120 265 | let start = TestGPXPoint.leipzig.with { $0.elevation = 100 } 266 | let points: [TestGPXPoint] = [ 267 | start, 268 | start.offset(east: 250).with { $0.elevation = nil }, 269 | start.offset(east: 400).with { $0.elevation = nil }, 270 | start.offset(east: 450).with { $0.elevation = nil }, 271 | start.offset(east: 500).with { $0.elevation = 120 }, 272 | start.offset(east: 600).with { $0.elevation = nil }, 273 | start.offset(east: 700).with { $0.elevation = nil }, 274 | start.offset(east: 800).with { $0.elevation = 300 } 275 | ] 276 | let result = try parseXML(given(points: points)) 277 | 278 | let expected: [Coordinate] = [ 279 | Coordinate(points[0]), 280 | Coordinate(points[1].with { $0.elevation = expectedElevation( 281 | start: points[0], 282 | end: points[4], 283 | distanceFromStart: points[0].distance(to: points[1]) 284 | ) }), 285 | Coordinate(points[2].with { $0.elevation = expectedElevation( 286 | start: points[0], 287 | end: points[4], 288 | distanceFromStart: points[0].distance(to: points[2]) 289 | ) }), 290 | Coordinate(points[3].with { $0.elevation = expectedElevation( 291 | start: points[0], 292 | end: points[4], 293 | distanceFromStart: points[0].distance(to: points[3]) 294 | ) }), 295 | Coordinate(points[4]), 296 | Coordinate(points[5].with { $0.elevation = expectedElevation( 297 | start: points[4], 298 | end: points[7], 299 | distanceFromStart: points[4].distance(to: points[5]) 300 | ) }), 301 | Coordinate(points[6].with { $0.elevation = expectedElevation( 302 | start: points[4], 303 | end: points[7], 304 | distanceFromStart: points[4].distance(to: points[6]) 305 | ) }), 306 | Coordinate(points[7]) 307 | ] 308 | 309 | expectNoDifference(expected, result.trackPoints.map(\.coordinate)) 310 | } 311 | 312 | @Test 313 | func testItTakesTheFirstElevationWhenTheTrackStartsWithNoElevation() throws { 314 | let start = TestGPXPoint.leipzig.with { $0.elevation = nil } 315 | let points: [TestGPXPoint] = [ 316 | start, 317 | start.offset(east: 250).with { $0.elevation = nil }, 318 | start.offset(east: 400).with { $0.elevation = nil }, 319 | start.offset(east: 500).with { $0.elevation = 120 } 320 | ] 321 | let result = try parseXML(given(points: points)) 322 | 323 | let expected: [Coordinate] = [ 324 | Coordinate(points[0].with { $0.elevation = 120 }), 325 | Coordinate(points[1].with { $0.elevation = 120 }), 326 | Coordinate(points[2].with { $0.elevation = 120 }), 327 | Coordinate(points[3]) 328 | ] 329 | 330 | expectNoDifference(expected, result.trackPoints.map(\.coordinate)) 331 | } 332 | 333 | @Test 334 | func testItTakesTheLastElevationWhenTheTrackEndsWithNoElevation() throws { 335 | let start = TestGPXPoint.leipzig.with { $0.elevation = 170 } 336 | let points: [TestGPXPoint] = [ 337 | start, 338 | start.offset(east: 250).with { $0.elevation = nil }, 339 | start.offset(east: 400), 340 | start.offset(east: 500).with { $0.elevation = nil } 341 | ] 342 | let result = try parseXML(given(points: points)) 343 | 344 | let expected: [Coordinate] = [ 345 | Coordinate(points[0]), 346 | Coordinate(points[1].with { $0.elevation = expectedElevation( 347 | start: points[0], 348 | end: points[2], 349 | distanceFromStart: points[0].distance(to: points[1]) 350 | ) }), 351 | Coordinate(points[2]), 352 | Coordinate(points[3].with { $0.elevation = 170 }) 353 | ] 354 | 355 | expectNoDifference(expected, result.trackPoints.map(\.coordinate)) 356 | } 357 | 358 | @Test 359 | func testEmptySegmentIsEmptyTrackPoints() throws { 360 | let result = try parseXML(given(points: [])) 361 | 362 | expectNoDifference([], result.trackPoints) 363 | } 364 | 365 | @Test 366 | func testParsingKeywords() throws { 367 | let result = try parseXML(""" 368 | 369 | 370 | 371 | one two three \n \t four 372 | 373 | 374 | Haus- und Seenrunde Ausdauer 375 | 1 376 | 377 | 378 | 114.2 379 | 380 | 381 | 114.0 382 | 383 | 384 | 385 | 386 | """) 387 | 388 | let sut = result 389 | expectNoDifference(["one", "two", "three", "four"], sut.keywords) 390 | } 391 | 392 | @Test 393 | func testParsingAFileWithoutWaypointDefinitionsHasEmptyWaypoints() throws { 394 | let sut = try parseXML(testXMLData) 395 | 396 | #expect(sut.waypoints == nil) 397 | } 398 | 399 | @Test 400 | func testParsingWaypointAttributes() throws { 401 | let sut = try parseXML(testXMLDataContainingWaypoint) 402 | 403 | let waypointStart = Waypoint( 404 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500), 405 | date: expectedDate(for: "2020-03-18T12:39:47Z"), 406 | name: "Start", 407 | comment: "start comment", 408 | description: "This is the start" 409 | ) 410 | 411 | let waypointFinish = Waypoint( 412 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760), 413 | date: expectedDate(for: "2020-03-18T12:39:48Z"), 414 | name: "Finish", 415 | comment: "finish comment", 416 | description: "This is the finish" 417 | ) 418 | 419 | expectNoDifference([waypointStart, waypointFinish], sut.waypoints) 420 | } 421 | 422 | @Test 423 | func testParsingRouteFiles() throws { 424 | let sut = try parseXML(""" 425 | 426 | 427 | 428 | Haus- und Seenrunde Ausdauer 429 | 430 | 114.2 431 | 432 | 433 | 114.0 434 | 435 | 436 | 437 | """) 438 | 439 | let expected = [ 440 | TrackPoint( 441 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 114.2), 442 | date: nil 443 | ), 444 | TrackPoint( 445 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 114), 446 | date: nil 447 | ) 448 | ] 449 | 450 | assertTracksAreEqual(GPXTrack( 451 | date: nil, 452 | title: "Haus- und Seenrunde Ausdauer", 453 | trackPoints: expected, type: nil 454 | ), sut) 455 | } 456 | 457 | @Test 458 | func testParsingTrackWithoutNameHaveAnEmptyName() throws { 459 | let result = try parseXML(""" 460 | 461 | 462 | 463 | 464 | 465 | 1 466 | 467 | 468 | 114.2 469 | 470 | 471 | 114.0 472 | 473 | 474 | 475 | 476 | """) 477 | 478 | let expected = [ 479 | TrackPoint( 480 | coordinate: Coordinate(latitude: 51.2760600, longitude: 12.3769500, elevation: 114.2), 481 | date: nil 482 | ), 483 | TrackPoint( 484 | coordinate: Coordinate(latitude: 51.2760420, longitude: 12.3769760, elevation: 114.0), 485 | date: nil 486 | ) 487 | ] 488 | 489 | assertTracksAreEqual( GPXTrack(date: nil, title: "", trackPoints: expected, type: "1" ), result) 490 | } 491 | 492 | @Test 493 | func testParsingWaypointWithEmptyTrack() throws { 494 | let input = """ 495 | 496 | 497 | 498 | gpxgenerator_path 499 | 500 | gpx.studio 501 | 502 | 503 | 504 | 505 | 506 | 8.4 507 | 508 | 509 | 510 | 511 | 8.3 512 | 513 | 514 | 515 | """ 516 | 517 | let sut = GPXFileParser(xmlString: input) 518 | 519 | let track = try sut.parse().get() 520 | expectNoDifference([], track.trackPoints) 521 | expectNoDifference([], track.graph.heightMap) 522 | expectNoDifference([], track.graph.segments) 523 | expectNoDifference(.zero, track.graph.distance) 524 | expectNoDifference(.zero, track.graph.elevationGain) 525 | expectNoDifference([ 526 | Waypoint(coordinate: .init(latitude: 53.060632820504345, longitude: 5.6932974383264616, elevation: 8.4)), 527 | Waypoint(coordinate: .init(latitude: 53.06485377614443, longitude: 5.702670398232679, elevation: 8.3)) 528 | ], track.waypoints) 529 | } 530 | 531 | @Test 532 | func testParsingWaypointWithoutTrack() throws { 533 | let input = """ 534 | 535 | 536 | 537 | gpxgenerator_path 538 | 539 | gpx.studio 540 | 541 | 542 | 543 | 544 | 8.4 545 | 546 | 547 | 548 | 549 | 8.3 550 | 551 | 552 | 553 | """ 554 | 555 | let sut = GPXFileParser(xmlString: input) 556 | 557 | let track = try sut.parse().get() 558 | expectNoDifference([], track.trackPoints) 559 | expectNoDifference([], track.graph.heightMap) 560 | expectNoDifference([], track.graph.segments) 561 | expectNoDifference(.zero, track.graph.distance) 562 | expectNoDifference(.zero, track.graph.elevationGain) 563 | expectNoDifference([ 564 | Waypoint(coordinate: .init(latitude: 53.060632820504345, longitude: 5.6932974383264616, elevation: 8.4)), 565 | Waypoint(coordinate: .init(latitude: 53.06485377614443, longitude: 5.702670398232679, elevation: 8.3)) 566 | ], track.waypoints) 567 | } 568 | 569 | @Test 570 | func testParsingTrackWithMultipleSegments() throws { 571 | let result = try parseXML(""" 572 | 573 | 578 | 579 | 580 | 581 | 1 582 | 583 | 584 | 51.71003342 585 | 586 | 587 | 588 | 51.7000351 589 | 590 | 591 | 592 | 51.7000351 593 | 594 | 595 | 596 | 51.67003632 597 | 598 | 599 | 600 | 51.66003799 601 | 602 | 603 | 604 | 605 | 606 | 54.38999939 607 | 608 | 609 | 610 | 54.34999847 611 | 612 | 613 | 614 | 54.22999954 615 | 616 | 617 | 618 | 54.16999817 619 | 620 | 621 | 622 | 54.13999939 623 | 624 | 625 | 626 | 627 | 628 | """) 629 | 630 | let expected = [ 631 | TrackPoint( 632 | coordinate: Coordinate(latitude: 53.0736462, longitude: 13.1756965, elevation: 51.71003342), 633 | date: expectedDate(for: "2023-05-20T08:20:07Z") 634 | ), 635 | TrackPoint( 636 | coordinate: Coordinate(latitude: 53.0736242, longitude: 13.1757405, elevation: 51.7000351), 637 | date: expectedDate(for: "2023-05-20T08:20:08Z") 638 | ), 639 | TrackPoint( 640 | coordinate: Coordinate(latitude: 53.0735992, longitude: 13.1757855, elevation: 51.7000351), 641 | date: expectedDate(for: "2023-05-20T08:20:09Z") 642 | ), 643 | TrackPoint( 644 | coordinate: Coordinate(latitude: 53.0735793, longitude: 13.1758284, elevation: 51.67003632), 645 | date: expectedDate(for: "2023-05-20T08:20:10Z") 646 | ), 647 | TrackPoint( 648 | coordinate: Coordinate(latitude: 53.0735543, longitude: 13.1758994, elevation: 51.66003799), 649 | date: expectedDate(for: "2023-05-20T08:20:11Z") 650 | ), 651 | // 2nd segment 652 | TrackPoint( 653 | coordinate: Coordinate(latitude: 53.186896, longitude: 13.132096, elevation: 54.38999939), 654 | date: expectedDate(for: "2023-05-20T10:35:19Z") 655 | ), 656 | TrackPoint( 657 | coordinate: Coordinate(latitude: 53.186909, longitude: 13.132093, elevation: 54.34999847), 658 | date: expectedDate(for: "2023-05-20T10:35:20Z") 659 | ), 660 | TrackPoint( 661 | coordinate: Coordinate(latitude: 53.1869289, longitude: 13.1320901, elevation: 54.22999954), 662 | date: expectedDate(for: "2023-05-20T10:35:21Z") 663 | ), 664 | TrackPoint( 665 | coordinate: Coordinate(latitude: 53.1869399, longitude: 13.1320881, elevation: 54.16999817), 666 | date: expectedDate(for: "2023-05-20T10:35:22Z") 667 | ), 668 | TrackPoint( 669 | coordinate: Coordinate(latitude: 53.1869479, longitude: 13.1320822, elevation: 54.13999939), 670 | date: expectedDate(for: "2023-05-20T10:35:23Z") 671 | ) 672 | ] 673 | 674 | assertTracksAreEqual( 675 | GPXTrack( 676 | date: nil, 677 | title: "", 678 | trackPoints: expected, 679 | segments: [ 680 | .init( 681 | range: 0 ..< 5, distance: expected[0 ..< 5].expectedDistance() 682 | ), 683 | .init( 684 | range: 5 ..< 10, distance: expected[5 ..< 10].expectedDistance() 685 | ) 686 | ], 687 | type: "1" 688 | ), 689 | result 690 | ) 691 | } 692 | } 693 | --------------------------------------------------------------------------------