Skip to content

Commit

Permalink
Apple Annotation parsing (#287)
Browse files Browse the repository at this point in the history
* Feat: annotation parsing on iOS

* Feat: annotation parsing on iOS

* Feat: annotation parsing on iOS

* Finalized annotation processing for speed limit

* Finalized annotation processing for speed limit

* Finalized annotation processing for speed limit

* Finalized annotation processing for speed limit

* Applied swiftformat

* removed no longer needed cargo maxspeed mps tests

* removed no longer needed cargo maxspeed mps tests

* removed no longer needed cargo maxspeed mps tests

* removed no longer needed cargo maxspeed mps tests

* removed no longer needed cargo maxspeed mps tests

* removed no longer needed cargo maxspeed mps tests

* Apply suggestions from code review

Co-authored-by: Ian Wagner <ian.wagner@stadiamaps.com>

* Improvements and testing from PR reivew

* Improvements and testing from PR reivew

* Improvements and testing from PR reivew

---------

Co-authored-by: Ian Wagner <ian.wagner@stadiamaps.com>
  • Loading branch information
Archdoog and ianthetechie authored Oct 29, 2024
1 parent f434b17 commit 14b41be
Show file tree
Hide file tree
Showing 27 changed files with 623 additions and 103 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ if useLocalFramework {
path: "./common/target/ios/libferrostar-rs.xcframework"
)
} else {
let releaseTag = "0.19.0"
let releaseTag = "0.20.0"
let releaseChecksum = "b3565c57b70ac72426e10e7d3c3020900c07548d0eede8200ef4d07edb617a22"
binaryTarget = .binaryTarget(
name: "FerrostarCoreRS",
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ ext {

allprojects {
group = "com.stadiamaps.ferrostar"
version = "0.19.0"
version = "0.20.0"
}
7 changes: 6 additions & 1 deletion apple/DemoApp/Demo/DemoNavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ struct DemoNavigationView: View {
profile: "bicycle",
locationProvider: locationProvider,
navigationControllerConfig: config,
options: ["costing_options": ["bicycle": ["use_roads": 0.2]]]
options: ["costing_options": ["bicycle": ["use_roads": 0.2]]],
annotation: AnnotationPublisher<ValhallaExtendedOSRMAnnotation>.valhallaExtendedOSRM()
)
// NOTE: Not all applications will need a delegate. Read the NavigationDelegate documentation for details.
ferrostarCore.delegate = navigationDelegate
Expand Down Expand Up @@ -90,6 +91,10 @@ struct DemoNavigationView: View {
CircleStyleLayer(identifier: "foo", source: source)
}
)
.navigationSpeedLimit(
speedLimit: ferrostarCore.annotation?.speedLimit,
speedLimitStyle: .usStyle
)
.innerGrid(
topCenter: {
if let errorMessage {
Expand Down
8 changes: 8 additions & 0 deletions apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@
path = "Preview Content";
sourceTree = "<group>";
};
163A9A422CBA23ED00E2AC0E /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
1663679B2B2F6F79008BFF1F /* Helpers */ = {
isa = PBXGroup;
children = (
Expand All @@ -99,6 +106,7 @@
1611A5522B2E6E98006B131D /* Demo */,
E9DD18E52B18F4BD00CAF29A /* LICENSE */,
E9DD18E42B18EE7A00CAF29A /* README.md */,
163A9A422CBA23ED00E2AC0E /* Frameworks */,
E9505FB82AD449700016BF0A /* Products */,
);
sourceTree = "<group>";
Expand Down
77 changes: 77 additions & 0 deletions apple/Sources/FerrostarCore/Annotations/AnnotationPublisher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Combine
import Foundation

/// A generic implementation of the annotation publisher.
/// To allow dynamic specialization in the core ``FerrostarCore/FerrostarCore/init(routeProvider:locationProvider:navigationControllerConfig:networkSession:annotation:)``
public protocol AnnotationPublishing {
associatedtype Annotation: Decodable

var currentValue: Annotation? { get }
var speedLimit: Measurement<UnitSpeed>? { get }

func configure(_ navigationState: Published<NavigationState?>.Publisher)
}

/// A class that publishes the decoded annotation object off of ``FerrostarCore``'s
/// ``NavigationState`` publisher.
public class AnnotationPublisher<Annotation: Decodable>: ObservableObject, AnnotationPublishing {
@Published public var currentValue: Annotation?
@Published public var speedLimit: Measurement<UnitSpeed>?

private let mapSpeedLimit: ((Annotation?) -> Measurement<UnitSpeed>?)?
private let decoder: JSONDecoder
private let onError: (Error) -> Void
private var cancellables = Set<AnyCancellable>()

/// Create a new annotation publisher with an instance of ``FerrostarCore``
///
/// - Parameters:
/// - mapSpeedLimit: Extract and convert the annotation types speed limit (if one exists).
/// - onError: A closure to run any time a `DecoderError` occurs.
/// - decoder: Specify a custom JSONDecoder if desired.
public init(
mapSpeedLimit: ((Annotation?) -> Measurement<UnitSpeed>?)? = nil,
onError: @escaping (Error) -> Void = { _ in },
decoder: JSONDecoder = JSONDecoder()
) {
self.mapSpeedLimit = mapSpeedLimit
self.onError = onError
self.decoder = decoder
}

/// Configure the AnnotationPublisher to run off of a specific navigation state published value.
///
/// - Parameter navigationState: Ferrostar's current navigation state.
public func configure(_ navigationState: Published<NavigationState?>.Publisher) {
// Important quote from Apple's Combine Docs @
// https://developer.apple.com/documentation/combine/just/assign(to:)#discussion:
//
// "The assign(to:) operator manages the life cycle of the subscription, canceling the subscription
// automatically when the Published instance deinitializes. Because of this, the assign(to:) operator
// doesn’t return an AnyCancellable that you’re responsible for like assign(to:on:) does."

navigationState
.map(decodeAnnotation)
.receive(on: DispatchQueue.main)
.assign(to: &$currentValue)

if let mapSpeedLimit {
$currentValue
.map(mapSpeedLimit)
.assign(to: &$speedLimit)
}
}

func decodeAnnotation(_ state: NavigationState?) -> Annotation? {
guard let data = state?.currentAnnotationJSON?.data(using: .utf8) else {
return nil
}

do {
return try decoder.decode(Annotation.self, from: data)
} catch {
onError(error)
return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// A Valhalla extended OSRM annotation object.
///
/// Describes attributes about a segment of an edge between two points
/// in a route step.
public struct ValhallaExtendedOSRMAnnotation: Codable, Equatable, Hashable {
enum CodingKeys: String, CodingKey {
case speedLimit = "maxspeed"
case speed
case distance
case duration
}

/// The speed limit of the segment.
public let speedLimit: MaxSpeed?

/// The estimated speed of travel for the segment, in meters per second.
public let speed: Double?

/// The distance in meters of the segment.
public let distance: Double?

/// The estimated time to traverse the segment, in seconds.
public let duration: Double?
}

public extension AnnotationPublisher {
/// Create a Valhalla extended OSRM annotation publisher
///
/// - Parameter onError: An optional error closure (runs when a `DecoderError` occurs)
/// - Returns: The annotation publisher.
static func valhallaExtendedOSRM(
onError: @escaping (Error) -> Void = { _ in }
) -> AnnotationPublisher<ValhallaExtendedOSRMAnnotation> {
AnnotationPublisher<ValhallaExtendedOSRMAnnotation>(
mapSpeedLimit: {
$0?.speedLimit?.measurementValue
},
onError: onError
)
}
}
38 changes: 31 additions & 7 deletions apple/Sources/FerrostarCore/FerrostarCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ public protocol FerrostarCoreDelegate: AnyObject {
/// The observable state of the model (for easy binding in SwiftUI views).
@Published public private(set) var state: NavigationState?

public let annotation: (any AnnotationPublishing)?

private let networkSession: URLRequestLoading
private let routeProvider: RouteProvider
private let locationProvider: LocationProviding
Expand All @@ -97,21 +99,35 @@ public protocol FerrostarCoreDelegate: AnyObject {
///
/// This designated initializer is the most flexible, but the convenience ones may be easier to use.
/// for common configuraitons.
///
/// - Parameters:
/// - routeProvider: The route provider is responsible for fetching routes from a server or locally.
/// - locationProvider: The location provider is responsible for tracking the user's location for navigation trip
/// updates.
/// - navigationControllerConfig: Configure the behavior of the navigation controller.
/// - networkSession: The network session to run route fetches on. A custom ``RouteProvider`` may not use this.
/// - annotation: An implementation of the annotation publisher that transforms custom annotation JSON into
/// published values of defined swift types.
public init(
routeProvider: RouteProvider,
locationProvider: LocationProviding,
navigationControllerConfig: SwiftNavigationControllerConfig,
networkSession: URLRequestLoading
networkSession: URLRequestLoading,
annotation: (any AnnotationPublishing)? = nil
) {
self.routeProvider = routeProvider
self.locationProvider = locationProvider
config = navigationControllerConfig
self.networkSession = networkSession
self.annotation = annotation

super.init()

// Location provider setup
locationProvider.delegate = self

// Annotation publisher setup
self.annotation?.configure($state)
}

/// Initializes a core instance for a Valhalla API accessed over HTTP.
Expand All @@ -125,13 +141,16 @@ public protocol FerrostarCoreDelegate: AnyObject {
/// automatically (like `format`), but this lets you add arbitrary options so you can access the full API.
/// - networkSession: The network session to use. Don't set this unless you need to replace the networking stack
/// (ex: for testing).
/// - annotation: An implementation of the annotation publisher that transforms custom annotation JSON into
/// published values of defined swift types.
public convenience init(
valhallaEndpointUrl: URL,
profile: String,
locationProvider: LocationProviding,
navigationControllerConfig: SwiftNavigationControllerConfig,
options: [String: Any] = [:],
networkSession: URLRequestLoading = URLSession.shared
networkSession: URLRequestLoading = URLSession.shared,
annotation: (any AnnotationPublishing)? = nil
) throws {
guard let jsonOptions = try String(
data: JSONSerialization.data(withJSONObject: options),
Expand All @@ -149,35 +168,40 @@ public protocol FerrostarCoreDelegate: AnyObject {
routeProvider: .routeAdapter(adapter),
locationProvider: locationProvider,
navigationControllerConfig: navigationControllerConfig,
networkSession: networkSession
networkSession: networkSession,
annotation: annotation
)
}

public convenience init(
routeAdapter: RouteAdapterProtocol,
locationProvider: LocationProviding,
navigationControllerConfig: SwiftNavigationControllerConfig,
networkSession: URLRequestLoading = URLSession.shared
networkSession: URLRequestLoading = URLSession.shared,
annotation: (any AnnotationPublishing)? = nil
) {
self.init(
routeProvider: .routeAdapter(routeAdapter),
locationProvider: locationProvider,
navigationControllerConfig: navigationControllerConfig,
networkSession: networkSession
networkSession: networkSession,
annotation: annotation
)
}

public convenience init(
customRouteProvider: CustomRouteProvider,
locationProvider: LocationProviding,
navigationControllerConfig: SwiftNavigationControllerConfig,
networkSession: URLRequestLoading = URLSession.shared
networkSession: URLRequestLoading = URLSession.shared,
annotation: (any AnnotationPublishing)? = nil
) {
self.init(
routeProvider: .customProvider(customRouteProvider),
locationProvider: locationProvider,
navigationControllerConfig: navigationControllerConfig,
networkSession: networkSession
networkSession: networkSession,
annotation: annotation
)
}

Expand Down
89 changes: 89 additions & 0 deletions apple/Sources/FerrostarCore/Models/MaxSpeed.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation

/// The OSRM formatted MaxSpeed. This is a custom field used by some API's like Mapbox,
/// Valhalla with OSRM json output, etc.
///
/// For more information see:
/// - https://wiki.openstreetmap.org/wiki/Key:maxspeed
/// - https://docs.mapbox.com/api/navigation/directions/#route-leg-object (search for `max_speed`)
/// - https://valhalla.github.io/valhalla/speeds/#assignment-of-speeds-to-roadways
public enum MaxSpeed: Codable, Equatable, Hashable {
public enum Units: String, Codable {
case kilometersPerHour = "km/h"
case milesPerHour = "mph"
case knots // "knots" are an option in core OSRM docs, though unsure if they're ever used in this context.
}

/// There is no speed limit (it's unlimited, e.g. German Autobahn)
case noLimit

/// The speed limit is not known.
case unknown

/// The speed limit is a known value and unit (this may be localized depending on the API).
case speed(Double, unit: Units)

enum CodingKeys: CodingKey {
case none
case unknown
case speed
case unit
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

if let none = try container.decodeIfPresent(Bool.self, forKey: .none),
none == true
{
// The speed configuration is `{none: true}` for unlimited.
self = .noLimit
} else if let unknown = try container.decodeIfPresent(Bool.self, forKey: .unknown),
unknown == true
{
// The speed configuration is `{unknown: true}` for unknown.
self = .unknown
} else if let value = try container.decodeIfPresent(Double.self, forKey: .speed),
let unit = try container.decodeIfPresent(Units.self, forKey: .unit)
{
// The speed is a known value with units. Some API's may localize, others only support a single unit.
self = .speed(value, unit: unit)
} else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Invalid MaxSpeed, see docstrings for reference links"
))
}
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)

switch self {
case .noLimit:
try container.encode(true, forKey: .none)
case .unknown:
try container.encode(true, forKey: .unknown)
case let .speed(value, unit: unit):
try container.encode(value, forKey: .speed)
try container.encode(unit, forKey: .unit)
}
}

/// The MaxSpeed as a measurement
public var measurementValue: Measurement<UnitSpeed>? {
switch self {
case .noLimit: .init(value: .infinity, unit: .kilometersPerHour)
case .unknown: nil
case let .speed(value, unit):
switch unit {
case .kilometersPerHour:
.init(value: value, unit: .kilometersPerHour)
case .milesPerHour:
.init(value: value, unit: .milesPerHour)
case .knots:
.init(value: value, unit: .knots)
}
}
}
}
3 changes: 3 additions & 0 deletions apple/Sources/FerrostarCore/NavigationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public struct NavigationState: Hashable {
return remainingSteps
}

/// The current geometry segment's annotations in a JSON string.
///
/// A segment is the line between two coordinates on the geometry.
public var currentAnnotationJSON: String? {
guard case let .navigating(_, _, _, _, _, _, _, _, annotationJson: annotationJson) = tripState else {
return nil
Expand Down
Loading

0 comments on commit 14b41be

Please sign in to comment.