Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apple Annotation parsing #287

Merged
merged 20 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e7463fb
Feat: annotation parsing on iOS
Archdoog Oct 7, 2024
437f997
Feat: annotation parsing on iOS
Archdoog Oct 7, 2024
ae47cc1
Feat: annotation parsing on iOS
Archdoog Oct 7, 2024
61bfb33
Finalized annotation processing for speed limit
Archdoog Oct 27, 2024
8ea598d
Finalized annotation processing for speed limit
Archdoog Oct 27, 2024
16aa02c
Merge branch 'main' of github.com:stadiamaps/ferrostar into feat/appl…
Archdoog Oct 27, 2024
6291f5d
Finalized annotation processing for speed limit
Archdoog Oct 27, 2024
eca8d45
Finalized annotation processing for speed limit
Archdoog Oct 27, 2024
0245214
Applied swiftformat
Archdoog Oct 27, 2024
2eb6a50
removed no longer needed cargo maxspeed mps tests
Archdoog Oct 27, 2024
d8f4378
removed no longer needed cargo maxspeed mps tests
Archdoog Oct 27, 2024
bf253d1
removed no longer needed cargo maxspeed mps tests
Archdoog Oct 27, 2024
674e2eb
removed no longer needed cargo maxspeed mps tests
Archdoog Oct 27, 2024
4c93a77
removed no longer needed cargo maxspeed mps tests
Archdoog Oct 27, 2024
0b62778
removed no longer needed cargo maxspeed mps tests
Archdoog Oct 27, 2024
dd35901
Apply suggestions from code review
Archdoog Oct 28, 2024
694923f
Merge branch 'main' of github.com:stadiamaps/ferrostar into feat/appl…
Archdoog Oct 28, 2024
de5a702
Improvements and testing from PR reivew
Archdoog Oct 28, 2024
76d5780
Improvements and testing from PR reivew
Archdoog Oct 28, 2024
e806cd2
Improvements and testing from PR reivew
Archdoog Oct 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
ianthetechie marked this conversation as resolved.
Show resolved Hide resolved
// 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)?
Archdoog marked this conversation as resolved.
Show resolved Hide resolved

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.
Archdoog marked this conversation as resolved.
Show resolved Hide resolved
/// - 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.
Archdoog marked this conversation as resolved.
Show resolved Hide resolved
}

/// 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
Loading