diff --git a/Package.swift b/Package.swift index 23605bf9..96230c9b 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/android/build.gradle b/android/build.gradle index fd537917..5d487cef 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -17,5 +17,5 @@ ext { allprojects { group = "com.stadiamaps.ferrostar" - version = "0.19.0" + version = "0.20.0" } diff --git a/apple/DemoApp/Demo/DemoNavigationView.swift b/apple/DemoApp/Demo/DemoNavigationView.swift index 4c78e220..f2b06271 100644 --- a/apple/DemoApp/Demo/DemoNavigationView.swift +++ b/apple/DemoApp/Demo/DemoNavigationView.swift @@ -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.valhallaExtendedOSRM() ) // NOTE: Not all applications will need a delegate. Read the NavigationDelegate documentation for details. ferrostarCore.delegate = navigationDelegate @@ -90,6 +91,10 @@ struct DemoNavigationView: View { CircleStyleLayer(identifier: "foo", source: source) } ) + .navigationSpeedLimit( + speedLimit: ferrostarCore.annotation?.speedLimit, + speedLimitStyle: .usStyle + ) .innerGrid( topCenter: { if let errorMessage { diff --git a/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj b/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj index 71208edb..be51013e 100644 --- a/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj +++ b/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj @@ -82,6 +82,13 @@ path = "Preview Content"; sourceTree = ""; }; + 163A9A422CBA23ED00E2AC0E /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 1663679B2B2F6F79008BFF1F /* Helpers */ = { isa = PBXGroup; children = ( @@ -99,6 +106,7 @@ 1611A5522B2E6E98006B131D /* Demo */, E9DD18E52B18F4BD00CAF29A /* LICENSE */, E9DD18E42B18EE7A00CAF29A /* README.md */, + 163A9A422CBA23ED00E2AC0E /* Frameworks */, E9505FB82AD449700016BF0A /* Products */, ); sourceTree = ""; diff --git a/apple/Sources/FerrostarCore/Annotations/AnnotationPublisher.swift b/apple/Sources/FerrostarCore/Annotations/AnnotationPublisher.swift new file mode 100644 index 00000000..6e038719 --- /dev/null +++ b/apple/Sources/FerrostarCore/Annotations/AnnotationPublisher.swift @@ -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? { get } + + func configure(_ navigationState: Published.Publisher) +} + +/// A class that publishes the decoded annotation object off of ``FerrostarCore``'s +/// ``NavigationState`` publisher. +public class AnnotationPublisher: ObservableObject, AnnotationPublishing { + @Published public var currentValue: Annotation? + @Published public var speedLimit: Measurement? + + private let mapSpeedLimit: ((Annotation?) -> Measurement?)? + private let decoder: JSONDecoder + private let onError: (Error) -> Void + private var cancellables = Set() + + /// 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?)? = 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.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 + } + } +} diff --git a/apple/Sources/FerrostarCore/Annotations/Models/ValhallaOSRMAnnotation.swift b/apple/Sources/FerrostarCore/Annotations/Models/ValhallaOSRMAnnotation.swift new file mode 100644 index 00000000..79ab5b3b --- /dev/null +++ b/apple/Sources/FerrostarCore/Annotations/Models/ValhallaOSRMAnnotation.swift @@ -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 { + AnnotationPublisher( + mapSpeedLimit: { + $0?.speedLimit?.measurementValue + }, + onError: onError + ) + } +} diff --git a/apple/Sources/FerrostarCore/FerrostarCore.swift b/apple/Sources/FerrostarCore/FerrostarCore.swift index b7816c54..1f2faa17 100644 --- a/apple/Sources/FerrostarCore/FerrostarCore.swift +++ b/apple/Sources/FerrostarCore/FerrostarCore.swift @@ -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 @@ -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. @@ -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), @@ -149,7 +168,8 @@ public protocol FerrostarCoreDelegate: AnyObject { routeProvider: .routeAdapter(adapter), locationProvider: locationProvider, navigationControllerConfig: navigationControllerConfig, - networkSession: networkSession + networkSession: networkSession, + annotation: annotation ) } @@ -157,13 +177,15 @@ public protocol FerrostarCoreDelegate: AnyObject { 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 ) } @@ -171,13 +193,15 @@ public protocol FerrostarCoreDelegate: AnyObject { 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 ) } diff --git a/apple/Sources/FerrostarCore/Models/MaxSpeed.swift b/apple/Sources/FerrostarCore/Models/MaxSpeed.swift new file mode 100644 index 00000000..ac4e4902 --- /dev/null +++ b/apple/Sources/FerrostarCore/Models/MaxSpeed.swift @@ -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? { + 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) + } + } + } +} diff --git a/apple/Sources/FerrostarCore/NavigationState.swift b/apple/Sources/FerrostarCore/NavigationState.swift index dc19f420..8de21b8a 100644 --- a/apple/Sources/FerrostarCore/NavigationState.swift +++ b/apple/Sources/FerrostarCore/NavigationState.swift @@ -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 diff --git a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift index 9306aa45..c9d7d430 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift @@ -7,7 +7,7 @@ import MapLibreSwiftUI import SwiftUI /// A navigation view that dynamically switches between portrait and landscape orientations. -public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingInnerGridView { +public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingInnerGridView, SpeedLimitViewHost { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection @State private var orientation = UIDevice.current.orientation @@ -19,6 +19,9 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn private var navigationState: NavigationState? private let userLayers: [StyleLayerDefinition] + public var speedLimit: Measurement? + public var speedLimitStyle: SpeedLimitView.SignageStyle? + public var topCenter: (() -> AnyView)? public var topTrailing: (() -> AnyView)? public var midLeading: (() -> AnyView)? @@ -35,11 +38,11 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn /// - styleURL: The map's style url. /// - camera: The camera binding that represents the current camera on the map. /// - navigationCamera: The default navigation camera. This sets the initial camera & is also used when the center - /// on user button it tapped. + /// on user button it tapped. /// - navigationState: The current ferrostar navigation state provided by the Ferrostar core. /// - minimumSafeAreaInsets: The minimum padding to apply from safe edges. See `complementSafeAreaInsets`. /// - onTapExit: An optional behavior to run when the ArrivalView exit button is tapped. When nil (default) the - /// exit button is hidden. + /// exit button is hidden. /// - makeMapContent: Custom maplibre symbols to display on the map view. public init( styleURL: URL, @@ -83,7 +86,8 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn case .landscapeLeft, .landscapeRight: LandscapeNavigationOverlayView( navigationState: navigationState, - speedLimit: nil, + speedLimit: speedLimit, + speedLimitStyle: speedLimitStyle, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, @@ -103,7 +107,8 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn default: PortraitNavigationOverlayView( navigationState: navigationState, - speedLimit: nil, + speedLimit: speedLimit, + speedLimitStyle: speedLimitStyle, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift index dc04a48a..f418ab50 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift @@ -8,7 +8,7 @@ import SwiftUI /// A landscape orientation navigation view that includes the InstructionsView and ArrivalView on the /// leading half of the screen. -public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView { +public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView, SpeedLimitViewHost { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection let styleURL: URL @@ -18,6 +18,9 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView private var navigationState: NavigationState? private let userLayers: [StyleLayerDefinition] + public var speedLimit: Measurement? + public var speedLimitStyle: SpeedLimitView.SignageStyle? + public var topCenter: (() -> AnyView)? public var topTrailing: (() -> AnyView)? public var midLeading: (() -> AnyView)? @@ -77,7 +80,8 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView LandscapeNavigationOverlayView( navigationState: navigationState, - speedLimit: nil, + speedLimit: speedLimit, + speedLimitStyle: speedLimitStyle, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift index d56107d7..ea38b097 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift @@ -19,6 +19,7 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView var bottomTrailing: (() -> AnyView)? var speedLimit: Measurement? + var speedLimitStyle: SpeedLimitView.SignageStyle? var showZoom: Bool var onZoomIn: () -> Void var onZoomOut: () -> Void @@ -29,6 +30,7 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView init( navigationState: NavigationState?, speedLimit: Measurement? = nil, + speedLimitStyle: SpeedLimitView.SignageStyle? = nil, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, @@ -38,6 +40,7 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView ) { self.navigationState = navigationState self.speedLimit = speedLimit + self.speedLimitStyle = speedLimitStyle self.showZoom = showZoom self.onZoomIn = onZoomIn self.onZoomOut = onZoomOut @@ -83,6 +86,7 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView // view appears NavigatingInnerGridView( speedLimit: speedLimit, + speedLimitStyle: speedLimitStyle, showZoom: showZoom, onZoomIn: onZoomIn, onZoomOut: onZoomOut, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift index c6dee567..56e53020 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift @@ -20,6 +20,7 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView var bottomTrailing: (() -> AnyView)? var speedLimit: Measurement? + var speedLimitStyle: SpeedLimitView.SignageStyle? var showZoom: Bool var onZoomIn: () -> Void var onZoomOut: () -> Void @@ -30,6 +31,7 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView init( navigationState: NavigationState?, speedLimit: Measurement? = nil, + speedLimitStyle: SpeedLimitView.SignageStyle? = nil, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, @@ -39,6 +41,7 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView ) { self.navigationState = navigationState self.speedLimit = speedLimit + self.speedLimitStyle = speedLimitStyle self.showZoom = showZoom self.onZoomIn = onZoomIn self.onZoomOut = onZoomOut @@ -58,6 +61,7 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView // view appears NavigatingInnerGridView( speedLimit: speedLimit, + speedLimitStyle: speedLimitStyle, showZoom: showZoom, onZoomIn: onZoomIn, onZoomOut: onZoomOut, @@ -82,7 +86,8 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView onTapExit: onTapExit ) } - }.padding(.top, instructionsViewSizeWhenNotExpanded.height) + } + .padding(.top, instructionsViewSizeWhenNotExpanded.height + 16) if case .navigating = navigationState?.tripState, let visualInstruction = navigationState?.currentVisualInstruction, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift index d59d7865..501eb3be 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift @@ -7,7 +7,7 @@ import MapLibreSwiftUI import SwiftUI /// A portrait orientation navigation view that includes the InstructionsView at the top. -public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView { +public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView, SpeedLimitViewHost { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection let styleURL: URL @@ -16,6 +16,9 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView private var navigationState: NavigationState? private let userLayers: [StyleLayerDefinition] + public var speedLimit: Measurement? + public var speedLimitStyle: SpeedLimitView.SignageStyle? + public var topCenter: (() -> AnyView)? public var topTrailing: (() -> AnyView)? public var midLeading: (() -> AnyView)? @@ -36,11 +39,11 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView /// - styleURL: The map's style url. /// - camera: The camera binding that represents the current camera on the map. /// - navigationCamera: The default navigation camera. This sets the initial camera & is also used when the center - /// on user button it tapped. + /// on user button it tapped. /// - navigationState: The current ferrostar navigation state provided by the Ferrostar core. /// - minimumSafeAreaInsets: The minimum padding to apply from safe edges. See `complementSafeAreaInsets`. /// - onTapExit: An optional behavior to run when the ArrivalView exit button is tapped. When nil (default) the - /// exit button is hidden. + /// exit button is hidden. /// - makeMapContent: Custom maplibre symbols to display on the map view. public init( styleURL: URL, @@ -79,7 +82,8 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView PortraitNavigationOverlayView( navigationState: navigationState, - speedLimit: nil, + speedLimit: speedLimit, + speedLimitStyle: speedLimitStyle, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, diff --git a/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift b/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift index 02ead005..3ee5eef1 100644 --- a/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift +++ b/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift @@ -21,7 +21,7 @@ public extension CustomizableNavigatingInnerGridView { @ViewBuilder topTrailing: @escaping () -> some View = { Spacer() }, @ViewBuilder midLeading: @escaping () -> some View = { Spacer() }, @ViewBuilder bottomTrailing: @escaping () -> some View = { Spacer() } - ) -> some View { + ) -> Self { var newSelf = self newSelf.topCenter = { AnyView(topCenter()) } newSelf.topTrailing = { AnyView(topTrailing()) } diff --git a/apple/Sources/FerrostarSwiftUI/ViewModifiers/SpeedLimitViewModifier.swift b/apple/Sources/FerrostarSwiftUI/ViewModifiers/SpeedLimitViewModifier.swift new file mode 100644 index 00000000..c5a02111 --- /dev/null +++ b/apple/Sources/FerrostarSwiftUI/ViewModifiers/SpeedLimitViewModifier.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftUI + +/// An extension for a NavigationView that can host a SpeedLimitView. +public protocol SpeedLimitViewHost where Self: View { + var speedLimit: Measurement? { get set } + var speedLimitStyle: SpeedLimitView.SignageStyle? { get set } +} + +public extension SpeedLimitViewHost { + /// Configure the NavigationView to display a speed limit + /// with a specific speed limit signage style. + /// + /// - Parameters: + /// - speedLimit: The current speed limit in the desired units to display. + /// - speedLimitStyle: The style of the signage (US-MUTCD or Vienna Convention). + /// - Returns: The modified NavigationView. + func navigationSpeedLimit( + speedLimit: Measurement?, + speedLimitStyle: SpeedLimitView.SignageStyle + ) -> Self { + var newSelf = self + newSelf.speedLimit = speedLimit + newSelf.speedLimitStyle = speedLimitStyle + return newSelf + } +} diff --git a/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift b/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift index 3207d814..6a04ebcb 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift @@ -7,6 +7,7 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection var speedLimit: Measurement? + var speedLimitStyle: SpeedLimitView.SignageStyle? var showZoom: Bool var onZoomIn: () -> Void @@ -30,6 +31,7 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView /// /// - Parameters: /// - speedLimit: The speed limit provided by the navigation state (or nil) + /// - speedLimitStyle: The speed limit style (Vienna Convention or MUTCD) /// - showZoom: Whether to show the provided zoom control or not. /// - onZoomIn: The on zoom in tapped action. This should be used to zoom the user in one increment. /// - onZoomOut: The on zoom out tapped action. This should be used to zoom the user out one increment. @@ -39,6 +41,7 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView /// map on the user). public init( speedLimit: Measurement? = nil, + speedLimitStyle: SpeedLimitView.SignageStyle? = nil, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, @@ -46,6 +49,7 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView onCenter: @escaping () -> Void = {} ) { self.speedLimit = speedLimit + self.speedLimitStyle = speedLimitStyle self.showZoom = showZoom self.onZoomIn = onZoomIn self.onZoomOut = onZoomOut @@ -56,9 +60,10 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView public var body: some View { InnerGridView( topLeading: { - if let speedLimit { + if let speedLimit, let speedLimitStyle { SpeedLimitView( speedLimit: speedLimit, + signageStyle: speedLimitStyle, valueFormatter: formatterCollection.speedValueFormatter, unitFormatter: formatterCollection.speedWithUnitsFormatter ) diff --git a/apple/Tests/FerrostarCoreTests/Annotations/AnnotationPublisherTests.swift b/apple/Tests/FerrostarCoreTests/Annotations/AnnotationPublisherTests.swift new file mode 100644 index 00000000..b4422cbe --- /dev/null +++ b/apple/Tests/FerrostarCoreTests/Annotations/AnnotationPublisherTests.swift @@ -0,0 +1,132 @@ +import Combine +import CoreLocation +import FerrostarCoreFFI +import SwiftUI +import XCTest +@testable import FerrostarCore + +final class AnnotationPublisherTests: XCTestCase { + @Published var fakeNavigationState: NavigationState? + var cancellables = Set() + + func testSpeedLimitConversion() throws { + let annotation = AnnotationPublisher.valhallaExtendedOSRM() + annotation.configure($fakeNavigationState) + + let exp = expectation(description: "speed limit converted") + annotation.$speedLimit + .compactMap { $0 } + .sink { speedLimit in + XCTAssertEqual(speedLimit.value, 15.0) + XCTAssertEqual(speedLimit.unit, .milesPerHour) + + exp.fulfill() + } + .store(in: &cancellables) + + let annotationJson = try encode( + ValhallaExtendedOSRMAnnotation( + speedLimit: .speed(15, unit: .milesPerHour), + speed: nil, + distance: nil, + duration: nil + ) + ) + + fakeNavigationState = makeNavigationState(annotationJson) + wait(for: [exp], timeout: 10) + } + + func testInvalidJSON() throws { + let exp = expectation(description: "json decoder error") + + let annotation = AnnotationPublisher.valhallaExtendedOSRM { error in + XCTAssert(error is DecodingError) + exp.fulfill() + } + annotation.configure($fakeNavigationState) + + annotation.$speedLimit + .compactMap { $0 } + .sink { _ in + XCTFail("Should never be a non-nil speed limit.") + } + .store(in: &cancellables) + + fakeNavigationState = makeNavigationState("broken-json") + wait(for: [exp], timeout: 10) + } + + func testUnhandledException() throws { + let annotation = AnnotationPublisher.valhallaExtendedOSRM() + annotation.configure($fakeNavigationState) + + let exp = expectation(description: "speed limit converted") + annotation.$speedLimit + .compactMap { $0 } + .sink { speedLimit in + XCTAssertEqual(speedLimit.value, 15.0) + XCTAssertEqual(speedLimit.unit, .kilometersPerHour) + + exp.fulfill() + } + .store(in: &cancellables) + + fakeNavigationState = makeNavigationState("broken-json") + + let annotationJson = try encode( + ValhallaExtendedOSRMAnnotation( + speedLimit: .unknown, + speed: nil, + distance: nil, + duration: nil + ) + ) + + fakeNavigationState = makeNavigationState(annotationJson) + + let annotationJsonTwo = try encode( + ValhallaExtendedOSRMAnnotation( + speedLimit: .speed(15, unit: .kilometersPerHour), + speed: nil, + distance: nil, + duration: nil + ) + ) + + fakeNavigationState = makeNavigationState(annotationJsonTwo) + + wait(for: [exp], timeout: 10) + } + + // MARK: Helpers + + func makeNavigationState(_ annotation: String) -> NavigationState { + NavigationState( + tripState: .navigating( + currentStepGeometryIndex: 1, + snappedUserLocation: UserLocation( + clCoordinateLocation2D: CLLocationCoordinate2D(latitude: 0.0, longitude: 0.0) + ), + remainingSteps: [], + remainingWaypoints: [], + progress: TripProgress( + distanceToNextManeuver: 1.0, + distanceRemaining: 2.0, + durationRemaining: 3.0 + ), + deviation: .noDeviation, + visualInstruction: nil, + spokenInstruction: nil, + annotationJson: annotation + ), + routeGeometry: [], + isCalculatingNewRoute: false + ) + } + + func encode(_ annotation: ValhallaExtendedOSRMAnnotation) throws -> String { + let data = try JSONEncoder().encode(annotation) + return String(data: data, encoding: .utf8)! + } +} diff --git a/apple/Tests/FerrostarCoreTests/Annotations/ValhallaExtendedOSRMAnnotationTests.swift b/apple/Tests/FerrostarCoreTests/Annotations/ValhallaExtendedOSRMAnnotationTests.swift new file mode 100644 index 00000000..2c689b15 --- /dev/null +++ b/apple/Tests/FerrostarCoreTests/Annotations/ValhallaExtendedOSRMAnnotationTests.swift @@ -0,0 +1,145 @@ +import XCTest +@testable import FerrostarCore + +final class ValhallaOSRMAnnotationTests: XCTestCase { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + func testEmptyAnnotations() throws { + try assertAnnotations { + ValhallaExtendedOSRMAnnotation( + speedLimit: nil, + speed: nil, + distance: nil, + duration: nil + ) + } + } + + func testMilesPerHour() throws { + try assertAnnotations { + ValhallaExtendedOSRMAnnotation( + speedLimit: .speed(15, unit: .milesPerHour), + speed: 11.0, + distance: 12.0, + duration: 13.0 + ) + } + } + + func testKilometersPerHour() throws { + try assertAnnotations { + ValhallaExtendedOSRMAnnotation( + speedLimit: .speed(15, unit: .kilometersPerHour), + speed: 11.0, + distance: 12.0, + duration: 13.0 + ) + } + } + + func testKnots() throws { + try assertAnnotations { + ValhallaExtendedOSRMAnnotation( + speedLimit: .speed(15, unit: .knots), + speed: 11.0, + distance: 12.0, + duration: 13.0 + ) + } + } + + func testNoLimit() throws { + try assertAnnotations { + ValhallaExtendedOSRMAnnotation( + speedLimit: MaxSpeed.noLimit, + speed: 11.0, + distance: 12.0, + duration: 13.0 + ) + } + } + + func testUnknown() throws { + try assertAnnotations { + ValhallaExtendedOSRMAnnotation( + speedLimit: .unknown, + speed: 11.0, + distance: 12.0, + duration: 13.0 + ) + } + } + + func test_decodeFromString_withMaxSpeed() throws { + // Unsupported/incomplete congestion is in the json, but ignored by our `ValhallaExtendedOSRMAnnotation` + // Codable. + let jsonString = + "{\"distance\":4.294596842089401,\"duration\":1,\"speed\":4.2,\"congestion\":\"low\",\"maxspeed\":{\"speed\":56,\"unit\":\"km/h\"}}" + guard let jsonData = jsonString.data(using: .utf8) else { + XCTFail("Could not convert string to data") + return + } + + let result = try decoder.decode(ValhallaExtendedOSRMAnnotation.self, from: jsonData) + + XCTAssertEqual(result.distance, 4.294596842089401) + XCTAssertEqual(result.duration, 1.0) + XCTAssertEqual(result.speed, 4.2) + XCTAssertEqual(result.speedLimit, .speed(56.0, unit: .kilometersPerHour)) + } + + func test_decodeFromString_incompleteModel_unlimitedMaxSpeed() throws { + let jsonString = "{\"maxspeed\":{\"none\":true}}" + guard let jsonData = jsonString.data(using: .utf8) else { + XCTFail("Could not convert string to data") + return + } + + let result = try decoder.decode(ValhallaExtendedOSRMAnnotation.self, from: jsonData) + + XCTAssertNil(result.distance) + XCTAssertNil(result.duration) + XCTAssertNil(result.speed) + XCTAssertEqual(result.speedLimit, .noLimit) + } + + func test_decodeFromString_unknownSpeed() throws { + let jsonString = "{\"distance\":2,\"duration\":1,\"speed\":3,\"maxspeed\":{\"unknown\":true}}" + guard let jsonData = jsonString.data(using: .utf8) else { + XCTFail("Could not convert string to data") + return + } + + let result = try decoder.decode(ValhallaExtendedOSRMAnnotation.self, from: jsonData) + + XCTAssertEqual(result.distance, 2.0) + XCTAssertEqual(result.duration, 1.0) + XCTAssertEqual(result.speed, 3.0) + XCTAssertEqual(result.speedLimit, .unknown) + } + + func test_decodeFromString_nilSpeed() throws { + let jsonString = "{\"distance\":2,\"duration\":1,\"speed\":3}" + guard let jsonData = jsonString.data(using: .utf8) else { + XCTFail("Could not convert string to data") + return + } + + let result = try decoder.decode(ValhallaExtendedOSRMAnnotation.self, from: jsonData) + + XCTAssertEqual(result.distance, 2.0) + XCTAssertEqual(result.duration, 1.0) + XCTAssertEqual(result.speed, 3.0) + XCTAssertNil(result.speedLimit) + } + + func assertAnnotations(_ makeAnnotations: () -> ValhallaExtendedOSRMAnnotation) throws { + let annotations = makeAnnotations() + + let encoded = try encoder.encode(annotations) + let result = try decoder.decode(ValhallaExtendedOSRMAnnotation.self, from: encoded) + + XCTAssertEqual(annotations, result) + } +} diff --git a/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift b/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift index 0972e514..f556ae78 100644 --- a/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift +++ b/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift @@ -3,22 +3,23 @@ import XCTest @testable import FerrostarSwiftUI final class NavigatingInnerGridViewTests: XCTestCase { - // TODO: enable once we decide on a method to expose the speed limit sign provider within the view stack. -// func testUSView() { -// assertView { -// NavigatingInnerGridView( -// speedLimit: .init(value: 55, unit: .milesPerHour), -// showZoom: true, -// showCentering: true -// ) -// .padding() -// } -// } + func test_USStyle_speedLimit_inGridView() { + assertView { + NavigatingInnerGridView( + speedLimit: .init(value: 55, unit: .milesPerHour), + speedLimitStyle: .usStyle, + showZoom: true, + showCentering: true + ) + .padding() + } + } - func testViennaStyleSpeedLimitInGridView() { + func test_ViennaConventionStyle_speedLimit_inGridView() { assertView { NavigatingInnerGridView( speedLimit: .init(value: 100, unit: .kilometersPerHour), + speedLimitStyle: .viennaConvention, showZoom: true, showCentering: true ) diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png new file mode 100644 index 00000000..78a60c07 Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/testViennaStyleSpeedLimitInGridView.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_ViennaConventionStyle_speedLimit_inGridView.1.png similarity index 100% rename from apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/testViennaStyleSpeedLimitInGridView.1.png rename to apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_ViennaConventionStyle_speedLimit_inGridView.1.png diff --git a/common/Cargo.lock b/common/Cargo.lock index 6d1a163d..b615bc6b 100644 --- a/common/Cargo.lock +++ b/common/Cargo.lock @@ -375,7 +375,7 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "ferrostar" -version = "0.19.0" +version = "0.20.0" dependencies = [ "assert-json-diff", "geo", diff --git a/common/ferrostar/Cargo.toml b/common/ferrostar/Cargo.toml index 9d40d7a0..5a620391 100644 --- a/common/ferrostar/Cargo.toml +++ b/common/ferrostar/Cargo.toml @@ -2,7 +2,7 @@ lints.workspace = true [package] name = "ferrostar" -version = "0.19.0" +version = "0.20.0" readme = "README.md" description = "The core of modern turn-by-turn navigation." keywords = ["navigation", "routing", "valhalla", "osrm"] diff --git a/common/ferrostar/src/routing_adapters/osrm/models.rs b/common/ferrostar/src/routing_adapters/osrm/models.rs index d84a462d..c8b97a0d 100644 --- a/common/ferrostar/src/routing_adapters/osrm/models.rs +++ b/common/ferrostar/src/routing_adapters/osrm/models.rs @@ -75,40 +75,6 @@ pub struct AnyAnnotation { pub values: HashMap>, } -/// The max speed json units that can be associated with annotations. -/// [MaxSpeed/Units](https://wiki.openstreetmap.org/wiki/Map_Features/Units#Speed) -/// TODO: We could generalize this as or map from this to a `UnitSpeed` enum. -#[derive(Deserialize, PartialEq, Debug, Clone)] -pub enum MaxSpeedUnits { - #[serde(rename = "km/h")] - KilometersPerHour, - #[serde(rename = "mph")] - MilesPerHour, -} - -/// The local posted speed limit between a pair of coordinates. -#[derive(Debug, Clone)] -#[allow(dead_code)] // TODO: https://github.com/stadiamaps/ferrostar/issues/271 -pub enum MaxSpeed { - Unknown { unknown: bool }, - Known { speed: f64, unit: MaxSpeedUnits }, -} - -#[allow(dead_code)] // TODO: https://github.com/stadiamaps/ferrostar/issues/271 -impl MaxSpeed { - /// Get the max speed as meters per second. - pub fn get_as_meters_per_second(&self) -> Option { - match self { - MaxSpeed::Known { speed, unit } => match unit { - MaxSpeedUnits::KilometersPerHour => Some(speed * 0.27778), - MaxSpeedUnits::MilesPerHour => Some(speed * 0.44704), - }, - #[allow(unused)] - MaxSpeed::Unknown { unknown } => None, - } - } -} - #[derive(Deserialize, Debug)] pub struct RouteStep { /// The distance from the start of the current maneuver to the following step, in meters. @@ -534,31 +500,4 @@ mod tests { Some(vec!["right".to_string()]) ); } - - #[test] - fn test_max_speed_unknown() { - let max_speed = MaxSpeed::Unknown { unknown: true }; - assert_eq!(max_speed.get_as_meters_per_second(), None); - } - - #[test] - fn test_max_speed_kph() { - let max_speed = MaxSpeed::Known { - speed: 100.0, - unit: MaxSpeedUnits::KilometersPerHour, - }; - assert_eq!( - max_speed.get_as_meters_per_second(), - Some(27.778000000000002) - ); - } - - #[test] - fn test_max_speed_mph() { - let max_speed = MaxSpeed::Known { - speed: 60.0, - unit: MaxSpeedUnits::MilesPerHour, - }; - assert_eq!(max_speed.get_as_meters_per_second(), Some(26.8224)); - } } diff --git a/web/package-lock.json b/web/package-lock.json index c8b40b4c..8281fbf2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stadiamaps/ferrostar-webcomponents", - "version": "0.18.0", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stadiamaps/ferrostar-webcomponents", - "version": "0.18.0", + "version": "0.20.0", "license": "BSD-3-Clause", "dependencies": { "@stadiamaps/ferrostar": "file:../common/ferrostar/pkg", @@ -26,7 +26,7 @@ }, "../common/ferrostar/pkg": { "name": "@stadiamaps/ferrostar", - "version": "0.18.0", + "version": "0.20.0", "license": "BSD-3-Clause" }, "node_modules/@ampproject/remapping": { diff --git a/web/package.json b/web/package.json index 88affd78..c0b1b855 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,7 @@ "CatMe0w (https://github.com/CatMe0w)", "Luke Seelenbinder " ], - "version": "0.19.0", + "version": "0.20.0", "license": "BSD-3-Clause", "type": "module", "main": "./dist/ferrostar-webcomponents.js",