Ferrostar
Ferrostar is a modern SDK for building turn-by-turn navigation applications. It’s designed for customizability from the ground up, helping you build the navigation experience your users deserve.
Ferrostar is...
- Modern - The core is written in Rust, making it easy to contribute to, maintain, and port to new architectures. Platform specific libraries for iOS and Android leverage the best features of Swift and Kotlin.
- Batteries included - The bundled navigation UI is usable out of the box for the most common use cases, with minimal reconfiguration needed. Don't like our UI? Most components are reusable and composable thanks to SwiftUI, Jetpack Compose, and Web Components.
- Extensible - At every layer, you have flexibility to extend or replace functionality without needing to wait for a patch. Want to bring your own offline routing? Can do. Want to use your own detection logic to see if the user is off the route? Not a problem. Taken together with the batteries included approach, Ferrostar's goal is to make simple things simple, and complex things possible.
- Vendor-neutral - As a corollary to its extensibility, Ferrostar is vendor-neutral, and welcomes PRs to add support for additional vendors. The core Ferrostar components do not upload telemetry to any vendor (though developers may add their own).
- Open-source - Ferrostar is open-source. No funky strings; just BSD.
Ferrostar is not...
- Aiming for compatibility with ancient SDKs / API levels, except where it’s easy; this is a rare chance for a fresh start.
- A routing engine, basemap, or search solution; there are many good vendors that provide hosted APIs and offline route generation, and there is a rich ecosystem of FOSS software if you're looking to host your own for a smaller deployment.
Can I use Ferrostar today?
On iOS and Android
Ferrostar is is currently in beta for iOS and Android, which means that it’s good to go for most use cases! There will be a few rough edges and missing features, but we’re here to help (check out the community links below).
The core is fully functional (pun intended for you FP lovers) and ready to handle most use cases that we’re aware of! If you’re already rolling a custom UI, you’re good to go!
iOS and Android have "batteries included" UIs which are highly composable in nature thanks to SwiftUI and Jetpack Compose. So you can customize most aspects of the UI today.
We know of at least half a dozen native app integrations underway, and the core devs are dogfooding in their own apps.
Using multiplatform frameworks
Ferrostar can be integrated into multiplatform frameworks in a few ways.
Both Flutter and React Native have mechanisms for calling platform/native code,
which you can use to create and interact with
the FerrostarCore
Swift and Kotlin classes.
Note that React Native is significantly more challenging as it is
deeply dependent on CocoaPods and has not yet adopted SPM (Ferrostar does).
If you are building a custom UI (ex: with flutter_maplibre_gl
or MapLibre React Native),
then this is all you need.
For the UI, both Flutter and React Native include functionality for hosting native views, and some community members are doing this successfully with Flutter already!
More idiomatic integrations are planned, and contributions are very much welcome. We are tracking status via the following issues:
On the web
The web platform is the newest addition to the family of supported platforms. It is currently alpha quality. We expect it to have the first beta release this autumn.
How to use this guide
This guide is broken up into several sections. The tutorial is designed to get you started quickly. Read this first (at least the chapter for your platform). Then you can pretty much skip around at will.
If you want to go deeper and customize the user experience, check out the chapters on customization.
The architecture section documents the design of Ferrostar and its various components. If you want to add support for a new routing API, post-process location updates, or contribute to the development of Ferrostar, this is where the authoritative docs live. (If you want to contribute, be sure to check out CONTRIBUTING.md!)
Connect with the Community
Feel free to open an issue or discussion on GitHub
for bug reports, feature requests, and questions.
You can also join the #ferrostar
channel on the OSM US Slack for updates + discussion.
The core devs are active there and happy to answer questions / help you get started!
Terminology and Conventions
In this guide, we have very specific definitions of certain terms, noted below. Cases where a more narrow interpretation is needed should be obvious.
- Interface - When used in a context that’s talking about code, we use this term to mean a method or type signature. For example, we will use the term interface to refer to Kotlin interfaces, Swift protocols, and Rust traits. We also use the term to refer to a type’s public interface as in the available properties to an end user such as yourself.
- Kotlin - We’ll be quite loose when talking about “Kotlin.” It would be too cumbersome to write out something like Kotlin/Java or “your favorite JVM language.” When we speak of Kotlin, we usually mean any JVM language, except when referring to specific Kotlin features. While all example code is in Kotlin, things should work equally well in Java.
- Platform - When we refer to “platform libraries”, the “platform layer”, similar, we are referring to code written for/targeting the end deployment platform directl (ex: iOS, Android, etc.).
Platform Support Targets
We’re building a navigation SDK for the future, but we acknowledge that your users live in the present. Our general policy is to expect developers to have up-to-date build tools, but support older devices where possible without compromising the maintainability and future-readiness of the project.
Rust
The core team develops using the latest stable Rust release.
At the moment, our MSRV is dictated by downstream projects.
Until the project is stable, it is unlikely that we will have a more formal MSRV policy,
but we do document the current MSRV in the root Cargo.toml
and verify it via CI.
Swift
The core requires Swift 5.9+. We are iterating on a more ergonomic wrapper for MapLibre Native on iOS, and this leverages macros, which drive this requirement.
iOS
iOS support starts at version 15. Our general policy will be to support the current and at least the previous major version, extending to two major versions if possible. At the time of this writing, the “current-1” policy covers 96% of iOS devices. This is a pretty standard adoption rate for iOS.
Android
Android developers should always build using the latest stable Android Studio version.
However, Android end users are much slower to get new OS major versions for many reasons. We currently support API level 25 and higher. At the time of this writing, the support target covers 96% of Android users. We will use publicly available data on API levels and developer feedback to set API level requirements going forward.
Android API Level caveats
API levels lower than 26 do not include support for several Java 8 APIs.
Crucially, the Instant
API, which is essential for the library, is not present.
We work around this using Java 8+ API desugaring support.
As we are able to raise our support target, we will remove the desugaring,
but for now, we need the compatibility shims / backports.
This requirement probably extends to your apps as well if you target API 25.
Additionally, when running on Android API lower than 30, we have to fall back on older ICU APIs. This does not require any change to your app code; it’s an internal consideration.
We recommend supporting the newest API version possible for your user base, as Google officially drops support for older releases after just a few years.
Quick Start Tutorials
This section contains quick start tutorials to get you going quickly with a basic navigation experience and the “batteries included” UI. Pick a platform to get started!
Getting Started on iOS
This section of the guide covers how to integrate Ferrostar into an iOS app. We'll cover the "batteries included" approach, but flag areas for customization and overrides along the way.
Add the Swift package dependency
If you’re not familiar with adding Swift Package dependencies to apps,
Apple has some helpful documentation.
You can search for the repository via its URL:
https://github.com/stadiamaps/ferrostar
.
Unless you are sure you know what you’re doing, you should use a tag (rather than a branch)
and update along with releases.
Since auto-generated bindings have to be checked in to source control
(due to how SPM works),
it’s possible to have intra-release breakage if you track master
.
Configure location services
To access the user’s real location, you first need to set a key in your Info.plist or similar file. This is something you can set in Xcode by going to your project, selecting the target, and going to the Info tab.
You need to add row for “Privacy - Location When In Use Usage Description”
(right-click any of the existing rows and click “Add row”)
or, if you’re using raw keys, NSLocationWhenInUseUsageDescription
.
Fill in a description of why your app needs access to their location.
Presumably something related to navigation ;)
Location providers
You'll need to configure a provider to get location updates. We bundle a few implementations to get you started, or you can create your own. The broad steps are the same regardless of which provider you use: create an instance of the class, store it in an instance variable where it makes sense, and (if simulating a route) set the location manually or enter a simulated route.
The API is similar to the iOS location APIs you may already know, and you can start or stop updates at will.
You should store your location provider in a place that is persistent for as long as you need it.
Most often this makes sense as a private @StateObject
if you’re using SwiftUI.
CoreLocationProvider
The CoreLocationProvider
provides a ready-to-go wrapper around a CLLocationManager
for getting location updates from GNSS.
It will automatically request permissions for you as part of initialization.
@StateObject private var locationProvider = CoreLocationProvider(activityType: .otherNavigation, allowBackgroundLocationUpdates: true)
NOTE: If you want to access the user’s location while the app is in the background,
you need to declare the location updates background mode in your Info.plist
.
You can find more details in the Apple documentation.
SimulatedLocationProvider
The SimulatedLocationProvider
allows for simulating location within Ferrostar
without needing GPX files or complicated environment setup.
This is great for testing and development without stepping outside.
First, instantiate the class. This is usually saved as an instance variable.
@StateObject private var locationProvider = SimulatedLocationProvider(location: initialLocation)
You can set a new location using the lastLocation
property at any time.
Optionally, once you have a route, simulate the replay of the route.
You can set a warpFactor
to play it back faster.
locationProvider.warpFactor = 2
locationProvider.setSimulatedRoute(route)
Configure the FerrostarCore
instance
Next, you’ll want to create a FerrostarCore
instance.
This is your interface into Ferrostar.
You’ll also want to keep this around as a persistent property
and use it in SwiftUI using a private @StateObject
or @ObservedObject
in most situations.
FerrostarCore
provides several initializers,
including convenient initializers for Valhalla routing backends
and the ability to provide your own custom routing (ex: for local/offline use).
Route Providers
You’ll need to decide on a route provider when you set up your FerrostarCore
instance.
For limited testing, FOSSGIS maintains a public server with the URL https://valhalla1.openstreetmap.de/route
.
For production use, you’ll need another solution like a commercial vendor
or self-hosting.
Set up Voice Guidance
If your routes include spoken instructions,
Ferrostar can trigger the speech synthesis at the right time.
Ferrostar includes the SpokenInstructionObserver
class,
which can use AVSpeechSynthesizer
or your own speech synthesis.
The SpeechSynthesizer
protocol
specifies the required interface,
and you can build your own implementation on this,
such as a local AI model or cloud service like Amazon Polly.
PRs welcome to add other publicly accessible speech API implementations.
Your navigation view can store the spoken instruction observer as an instance variable:
@State private var spokenInstructionObserver = SpokenInstructionObserver.initAVSpeechSynthesizer()
Then, you'll need to configure FerrostarCore
to use it.
ferrostarCore.spokenInstructionObserver = spokenInstructionObserver
Finally, you can use this to drive state on navigation view.
DynamicallyOrientingNavigationView
has constructor arguments to configure the mute button UI.
See the demo app for an example.
Configure annotation parsing
FerrostarCore
includes support for parsing arbitrary annotations
from the route.
This technique is a de facto standard from OSRM,
and has been adopted by a wide range of open-source and proprietary solutions.
The routing APIs from Stadia Maps, Mapbox, and others
use this to include detailed information like speed limits,
expected travel speed, and more.
Ferrostar includes a Valhalla extended OSRM annotation parser, which works with Valhalla-powered APIs including Stadia Maps. The implementation is completely generic, so you can define your own model to include custom parameters. PRs welcome for other public API annotation models.
To set up annotation parsing,
simply pass the optional annotation:
parameter
to the FerrostarCore
constructor.
You can create a Valhalla extended OSRM annotation publisher like so:
AnnotationPublisher<ValhallaExtendedOSRMAnnotation>.valhallaExtendedOSRM()
Getting a route
Before getting routes, you’ll need the user’s current location.
You can get this from the location provider (which is part of why you’ll want to hang on to it).
Next, you’ll need a set of waypoints to visit.
Finally, you can use the asynchronous getRoutes
method on FerrostarCore
.
Here’s an example:
Task {
do {
routes = try await ferrostarCore.getRoutes(initialLocation: userLocation, waypoints: [Waypoint(coordinate: GeographicCoordinate(cl: loc.location), kind: .break)])
errorMessage = nil
// TODO: Let the user select a route, or pick one programmatically
} catch {
// Communicate the error to the user.
errorMessage = "Error: \(error)"
}
}
Starting a navigation session
Once you or the user has selected a route, it’s time to start navigating!
try ferrostarCore.startNavigation(route: route, config: SwiftNavigationControllerConfig(stepAdvance: .relativeLineStringDistance(minimumHorizontalAccuracy: 32, automaticAdvanceDistance: 10), routeDeviationTracking: .staticThreshold(minimumHorizontalAccuracy: 25, maxAcceptableDeviation: 25)))
From this point, FerrostarCore
automatically starts the LocationProvider
updates,
and will use Combine, the SwiftUI observation framework, to publish state changes.
Using the DynamicallyOrientingNavigationView
So now navigation is “started” but what does that mean? Let’s turn these state updates into a familiar map-centric experience!
We’ll use the DynamicallyOrientingNavigationView
together with a map style,
the state from the core, and many more configurable properties
See the class documentation for details on each parameter,
but you have the ability to customize most of the camera behavior.
// You can get a free Stadia Maps API key at https://client.stadiamaps.com
// See https://stadiamaps.github.io/ferrostar/vendors.html for additional vendors
let styleURL = URL(string: "https://tiles.stadiamaps.com/styles/outdoors.json?api_key=\(stadiaMapsAPIKey)")!
DynamicallyOrientingNavigationView(
styleURL: styleURL,
navigationState: state,
camera: $camera,
snappedZoom: .constant(18),
useSnappedCamera: .constant(true))
Preventing the screen from sleeping
If you’re navigating, you probably don’t want the screen to go to sleep.
You can prevent this by setting the isIdleTimerDisabled
property on the UIApplication.shared
object.
UIApplication.shared.isIdleTimerDisabled = true
// Don't forget to re-enable it when you're done!
UIApplication.shared.isIdleTimerDisabled = false
Refer to the Apple documentation for more information.
Demo app
We've put together a minimal demo app with an example integration.
Going deeper
This covers the basic “batteries included” configuration and pre-built UI. But there’s a lot of room for customization! Skip on over to the customization chapters that interest you.
Getting Started on Android
This section of the guide covers how to integrate Ferrostar into an Android app. We'll cover the "batteries included" approach, but flag areas for customization and overrides along the way.
Gradle setup
Add dependencies
Let’s get started with Gradle setup.
Replace X.Y.Z
with the latest release version.
build.gradle
with explicit version strings
If you’re using the classic build.gradle
with implementation
strings using hard-coded versions,
here’s how to set things up.
dependencies {
// Elided: androidx dependencies: ktx and compose standard deps
// Ferrostar
def ferrostarVersion = 'X.Y.Z'
implementation "com.stadiamaps.ferrostar:core:${ferrostarVersion}"
implementation "com.stadiamaps.ferrostar:maplibreui:${ferrostarVersion}"
// Optional - if using Google Play Service's FusedLocation
implementation "com.stadiamaps.ferrostar:google-play-services:${ferrostarVersion}"
// okhttp3
implementation platform("com.squareup.okhttp3:okhttp-bom:4.11.0")
implementation 'com.squareup.okhttp3:okhttp'
}
libs.versions.toml
“modern style”
If you’re using the newer libs.versions.toml
approach,
add the versions like so:
[versions]
ferrostar = "X.Y.X"
okhttp3 = "4.11.0"
[libraries]
ferrostar-core = { group = "com.stadiamaps.ferrostar", name = "core", version.ref = "ferrostar" }
ferrostar-maplibreui = { group = "com.stadiamaps.ferrostar", name = "maplibreui", version.ref = "ferrostar" }
okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" }
Then reference it in your build.gradle
:
dependencies {
// Elided: androidx dependencies: ktx and compose standard deps
// Ferrostar
implementation libs.ferrostar.core
implementation libs.ferrostar.maplibreui
// okhttp3
implementation libs.okhttp3
}
Configure location services
Declaring permissions used
Your app will need access to the user’s location. First, you’ll need the requisite permissions in your Android manifest:
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
You’ll then need to request permission from the user to access their precise location.
Requesting location access
The “best” way to do this tends to change over time and varies with your app structure.
If you’re using Jetpack Compose,
you’ll want the rememberLauncherForActivityResult
API.
If you’re just using plain activities,
the registerForActivityResult
has what you need.
In either case, you’ll want to review Google’s documentation.
Ensuring updates when the app loses focus
Note that Ferrostar does not require “background” location access! This may be confusing if you’re new to mobile development. On Android, we can use something called a foreground service which lets us keep getting location updates even when the app isn’t front and center. This is such a detailed topic that it gets its own page! Learn about Foreground Service configuration here.
Location providers
You'll need to configure a provider to get location updates. We bundle a few implementations to get you started, or you can create your own. The broad steps are the same regardless of which provider you use: create an instance of the class, store it in an instance variable where it makes sense, and (if simulating a route) set the location manually or enter a simulated route.
Similar to the Android location APIs you may already know, you can add or remove listeners which will receive updates.
Google Play Fused Location Client
If your app uses Google Play Services,
you can use the FusedLocationProvider
This normally offers better device positioning than the default Android location provider
on supported devices.
To make use of it,
you will need to include the optional implementation "com.stadiamaps.ferrostar:google-play-services:${ferrostarVersion}"
in your Gradle dependencies block.
Just as with the AndroidSystemLocationProvider
,
you probably need to declare it as a lateinit var
instance variable first,
and then initialize later once the Context
is available.
// Instance variable definition
private lateinit var locationProvider: FusedLocationProvider
// Later when the activity loads and a context is available
locationProvider = FusedLocationProvider(context = this)
AndroidSystemLocationProvider
The AndroidSystemLocationProvider
uses the location provider
from the Android open-source project.
This is not as good as the proprietary Google fused location client,
but it will run on any Android phone,
including ones without Google Play Services.
It is also compatible with stores like F-Droid,
which require all apps use open-source software.
Initializing this provider requires an Android Context
,
so you probably need to declare it as a lateinit var
instance variable.
private lateinit var locationProvider: AndroidSystemLocationProvider
You can initialize it like so.
In an Activity
, the context is simply this
.
In other cases, get a context using an appropriate method.
locationProvider = AndroidSystemLocationProvider(context = this)
SimulatedLocationProvider
The SimulatedLocationProvider
allows for simulating location within Ferrostar
without needing GPX files or complicated environment setup.
This is great for testing and development without stepping outside.
First, instantiate the class. This will typically be saved as an instance variable.
private val locationProvider = SimulatedLocationProvider()
Later, most likely somewhere in your activity creation code or similar, set a location to your desired simulation start point.
locationProvider.lastLocation = initialSimulatedLocation
Once you have a route, you can simulate the replay of the route.
This is technically optional (you can just set lastLocation
yourself too),
but playing back a route saves you the effort.
You can set a warpFactor
to play it back faster.
locationProvider.warpFactor = 2u
locationProvider.setSimulatedRoute(route)
You don’t need to do anything else after setting a simulated route;
FerrostarCore
will automatically add itself as a listener,
which will trigger updates.
Configure an HTTP client
Before we configure the Ferrostar core, we need to set up an HTTP client. This is typically stored as an instance variable in one of your classes (ex: activity). We use the popular OkHttp library for this. Here we’ve set up a client with a global timeout of 15 seconds. Refer to the OkHttp documentation for further details on configuration.
private val httpClient = OkHttpClient.Builder()
.callTimeout(Duration.ofSeconds(15))
.build()
Configure the FerrostarCore
instance
We now have all the pieces we need to set up a FerrostarCore
instance!
The FerrostarCore
instance should live for at least the duration of a navigation session.
Bringing it all together, a typical init looks something like this:
private val core =
FerrostarCore(
valhallaEndpointURL = URL("https://api.stadiamaps.com/route/v1?api_key=YOUR-API-KEY"),
profile = "bicycle",
httpClient = httpClient,
locationProvider = locationProvider,
foregroundServiceManager = foregroundServiceManager,
navigationControllerConfig =
NavigationControllerConfig(
StepAdvanceMode.RelativeLineStringDistance(
minimumHorizontalAccuracy = 25U, automaticAdvanceDistance = 10U),
RouteDeviationTracking.StaticThreshold(15U, 50.0),
CourseFiltering.SNAP_TO_ROUTE),
)
FerrostarCore
exposes a number of convenience constructors for common cases,
such as using a Valhalla Route Provider.
FerrostarCore
automatically subscribes to location updates from the LocationProvider
.
Set up voice guidance
If your routes include spoken instructions,
Ferrostar can trigger the speech synthesis at the right time.
Ferrostar includes the AndroidTtsObserver
class,
which uses the text-to-speech engine built into Android.
You can also use your own implementation,
such as a local AI model or cloud service like Amazon Polly.
The com.stadiamaps.ferrostar.core.SpokenInstructionObserver
interface
specifies the required API.
PRs welcome to add other publicly accessible speech API implementations.
TODO documentation:
- Android Manifest
- Set the language
- Additional config (you have full control; link to Android docs)
Getting a route
Getting a route is easy! All you need is the start location (from the location provider) and a list of at least one waypoint to visit.
val routes =
core.getRoutes(
userLocation,
listOf(
Waypoint(
coordinate = GeographicCoordinate(37.807587, -122.428411),
kind = WaypointKind.BREAK),
))
Note that this is a suspend
function, so you’ll need to use it within a coroutine scope.
You probably want something like launch(Dispatchers.IO) { .. }
for most cases to ensure it’s running on the correct dispatcher.
You may select a different dispatcher if you are doing offline route calculation.
Starting a navigation session
Once you have a route (ex: by grabbing the first one from the list or by presenting the user with a selection screen), you’re ready to start a navigation session!
When you start a navigation session, the core returns a view model which will automatically be updated with state updates.
You can save “rememberable” state somewhere near the top of your composable block like so:
var navigationViewModel by remember { mutableStateOf<NavigationViewModel?>(null) }
And then use it to store the result of your startNavigation
invocation:
core.startNavigation(
route = route,
config =
NavigationControllerConfig(
StepAdvanceMode.RelativeLineStringDistance(
minimumHorizontalAccuracy = 25U, automaticAdvanceDistance = 10U),
RouteDeviationTracking.StaticThreshold(25U, 10.0)),
)
Finally, If you’re simulating route progress
(ex: in the emulator) with a SimulatedLocationProvider
),
set the route:
locationProvider.setSimulatedRoute(route)
Using the DynamicallyOrientingNavigationView
We’re finally ready to put this together into a beautiful navigation map!
FerrostarCore
exposes a state flow,
which you can incorporate into your own view model,
which must implement the NavigationViewModel
interface.
See the DemoNavigationViewModel
for an example of what a view model might look like.
Here’s an example:
// You can get a free Stadia Maps API key at https://client.stadiamaps.com.
// See https://stadiamaps.github.io/ferrostar/vendors.html for additional vendors
DynamicallyOrientingNavigationView(
styleUrl =
"https://tiles.stadiamaps.com/styles/outdoors.json?api_key=$stadiaApiKey",
viewModel = viewModel) { uiState ->
// You can add your own overlays here!
// See the DemoNavigationScene or https://github.com/Rallista/maplibre-compose-playground
// for some examples.
}
Tools for Improving a NavigationView
KeepScreenOnDisposableEffect
is a simple disposable effect that automatically keeps the screen on and at consistent brightness while a user is on the scene using the effect. On dispose, the screen will return to default and auto lock and dim. See the demo app for an example of this being used alongside theDynamicallyOrientingNavigationView
.
Demo app
We've put together a minimal demo app to show how to integrate Ferrostar into your Android app.
Going deeper
This covers the basic “batteries included” configuration and pre-built UI. But there’s a lot of room for customization! Skip on over to the customization chapters that interest you.
Getting Started on the Web
This section of the guide covers how to integrate Ferrostar into a web app. While there are limitations to the web Geolocation API (notably no background updates), PWAs and other mobile-optimized sites can be a great solution when a native iOS/Android app is impractical or prohibitively expensive.
We'll cover the "batteries included" approach, but flag areas for customization and overrides along the way.
Add the package dependency
Installing with npm
No surprises; just install with npm
or any similar package manager.
npm install @stadiamaps/ferrostar-webcomponents
Vite Setup
Vite currently has a few bundling issues with npm packages leveraging WASM. We are hopeful that the ES module integration proposal for WebAssembly is eventually finalized and widely accepted, but in the meantime there are some integration pains.
We currently recommend using vite-plugin-wasm
.
Add vite-plugin-wasm
and vite-plugin-top-level-await
to your devDependencies
.
Then add wasm()
and topLevelAwait()
to the plugins
section of your Vite config.
Using unpkg
TODO
Add Ferrostar web components to your web app
The Ferrostar web SDK uses the Web Components to ensure maximum compatibility across frontend frameworks. You can import the components just like other things you’re used to in JavaScript.
import { FerrostarMap, BrowserLocationProvider } from "@stadiamaps/ferrostar-components";
Configure the <ferrostar-map>
component
Now you can use Ferrostar in your HTML like this:
<ferrostar-map
id="ferrostar"
valhallaEndpointUrl="https://api.stadiamaps.com/route/v1"
styleUrl="https://tiles.stadiamaps.com/styles/outdoors.json"
profile="bicycle"
></ferrostar-map>
Here we have used Stadia Maps URLs, which should work without authentication for local development. (Refer to the authentication docs for network deployment details; you can start with a free account.)
See the vendors appendix for a list of other compatible vendors.
<ferrostar-map>
additionally requires setting some CSS manually, or it will be invisible!
ferrostar-map {
display: block;
width: 100%;
height: 100%;
}
That’s all you need to get started!
Configuration explained
<ferrostar-map>
provides a few properties to configure.
Here are the most important ones:
valhallaEndpointUrl
: The Valhalla routing endpoint to use. You can use any reasonably up-to-date Valhalla server, including your own. See vendors for a list of known compatible vendors.httpClient
: You can set your own fetch-compatible HTTP client to make requests to the routing API (ex: Valhalla).costingOptions
: You can set the costing options for the route provider (ex: Valhalla JSON options).locationProvider
: Provides locations to the navigation controller.SimulatedLocationProvider
andBrowserLocationProvider
are included. See the demo app for an example of how to simulate a route.configureMap
: Configures the map on first load. This lets you customize the UI, add MapLibre map controls, etc. on load.onNavigationStart
: Callback when navigation starts.onNavigationStop
: Callback when navigation ends.customStyles
: Custom CSS to load (the component uses a scoped shadow DOM; use this to load external styles).useVoiceGuidance
: Enable voice guidance (default:false
).geolocateOnLoad
: Geolocate the user on load and zoom the map to their location (default:true
; you probably want this unless you are simulating locations for testing).customStyles
: Styles which will apply inside the component (ex: for MapLibre plugins).
If you haven’t worked with web components before, one quick thing to understand is that the only thing you can configure using pure HTML are string attributes. Rich properties of any other type will not be properly passed through if you are specifying HTML attributes! If you’re using a vanilla framework, you will need to get the DOM object and then set properties with JavaScript like so:
const ferrostar = document.getElementById("ferrostar");
ferrostar.center = {lng: -122.42, lat: 37.81};
ferrostar.zoom = 18;
ferrostar.costingOptions = { bicycle: { use_roads: 0.2 } };
Other frameworks, like Vue, have better support for web components.
In Vue, you can write “markup” in your components like this!
However, there are a few gotchas.
The properties need to be written as camelCase, for one,
and some IDEs do not correctly suggest code completion.
You’ll also want to continue using JS (ex: via the onMounted
hook in Vue)
for most complex properties like functions.
<ferrostar-web
id="ferrostar"
valhallaEndpointUrl="https://api.stadiamaps.com/route/v1"
styleUrl="https://tiles.stadiamaps.com/styles/outdoors.json"
profile="bicycle"
:center="{lng: -122.42, lat: 37.81}"
:zoom="18"
:useVoiceGuidance="true"
:geolocateOnLoad="true"
></ferrostar-web>
NOTE: The JavaScript API is currently limited to Valhalla, but support for arbitrary providers (like we already have on iOS and Android) is tracked in this issue.
Acquiring the user’s location
The BrowserLocationProvider
includes a convenience function
to get the user’s location asynchronously (using a cached one if available).
Use this to get the user’s location in the correct format.
// Fetch the user's current location.
// If we have a cached one that's no older than 30 seconds,
// skip waiting for an update and use the slightly stale location.
const location = await ferrostar.locationProvider.getCurrentLocation(30_000);
Getting routes
Once you have acquired the user’s location and have one or more waypoints to navigate to, it’s time to get some routes!
// Use the acquired user location to request the route
const routes = await ferrostar.getRoutes(location, waypoints);
// Select one of the routes; here we just pick the first one.
const route = routes[0];
Starting navigation
Finally, we can start navigating! We’re still working on getting full documentation generated in the typescript wrapper, but in the meantime, the Rust docs describe the available options.
ferrostar.startNavigation(route, {
stepAdvance: {
RelativeLineStringDistance: {
minimumHorizontalAccuracy: 25,
automaticAdvanceDistance: 10,
},
},
routeDeviationTracking: {
StaticThreshold: {
minimumHorizontalAccuracy: 25,
maxAcceptableDeviation: 10.0,
},
},
snappedLocationCourseFiltering: "Raw",
});
Demo app
We've put together a minimal demo app with an example integration. Check out the source code or try the hosted demo (works best from a phone if you want to use real geolocation).
Going deeper
This covers the basic “batteries included” configuration and pre-built UI. But there’s a lot of room for customization! Skip on over to the customization chapters that interest you.
Rust
Not using a platform listed / building an embedded application? You're in the right spot!
Ferrostar is built in Rust, which makes it portable to a wide range of OS and CPU combinations. The core includes common data models, traits, common routing backend integrations, and more.
The core documentation is hosted, like every crate, on docs.rs.
The core of a custom navigation experience is the NavigationController
.
The controller is initialized with a route and configuration.
You can either construct a route yourself manually,
or use some of the existing tooling to get started.
Unlike the higher level platforms like iOS and Swift,
no high-level core wrapper handles HTTP for you (to keep the core light),
but you can add your own using a crate like reqwest
,
or you could run an embedded routing engine for offline routing.
If your routing API uses a common response format like OSRM,
check out the included parsers.
The NavigationController
is pure (in a functional sense),
and it is up to integrators to decide on the most appropriate state storage mechanism.
Use the get_initial_state
function to create an initial state with the user’s location.
Then, as new location updates arrive, or you decide to manually advance to the next step,
call the appropriate methods.
Each method call returns a new TripState
,
which you can store and take any actions on
(such as updating the UI or deciding to recalculate the route).
At a high level, that’s pretty much it! The Ferrostar core includes most of the proverbial legos; the rest is up to you.
General Customizations
Customizing FerrostarCore
behaviors
FerrostarCore
sits at the heart of your interactions with Ferrostar.
It provides an idiomatic way to interact with the core
from native code at the platform layer.
Ferrostar NavigationControllerConfig
options
These customizations apply to the NavigationController
.
They are surfaced at the platform layer
as an argument to the startNavigation
method of FerrostarCore
.
Step advance
The step advance mode controls when navigation advances to the next step in the route.
See the StepAdvanceMode
for details
(either in the rustdocs
or in the equivalent platform library docs;
the API is bridged idiomatically to Swift and Kotlin).
Ferrostar currently includes several simplistic methods, but more implementations are welcome!
You can use the manual step advance mode to disable automatic progress,
and trigger advance on your own using the advanceToNextStep
method
on FerrostarCore
at the platform layer.
Route deviation tracking
Ferrostar recognizes that there is no one-size-fits-all solution for determining when a user has deviated from the intended route. The core provides several configurable detection strategies, but you can also bring your own. (And yes, you can write your custom deviation detection code in your native mobile codebase.)
See the RouteDeviationTracking
rustdocs
for the list of available strategies and parameters.
(The Rust API is bridged idiomatically to Swift and Kotlin.)
Configuring a RouteDeviationHandler
By default, Ferrostar will fetch new routes when the user goes off course.
You can override this behavior with hooks on FerrostarCore
.
On iOS, all customization happens via the delegate.
Implement the core:correctiveActionForDeviation:remainingWaypoints
method
on your delegate and you’re all set.
On Android, set the deviationHandler
property.
Refer to the demo apps for custom implementation examples.
Note that you can disable the default behavior of attempting to reroute
by setting an empty implementation
(not setting the property to nil
/null
!).
Alternate route handling
FerrostarCore
may occasionally load alternative routes.
At this point, this is only used for recalculating when the user deviates from the route,
but it may be used for additional behaviors in the future,
such as speculative route checking based on current traffic (for supported vendors).
By default, Ferrostar will “accept” the new route and reroute the active navigation session whenever a new route arrives while the user is off course.
On iOS, you can configure this behavior by adding a delegate to the core
and implementing the core:loadedAlternateRoutes
method.
On Android, you can configure this behavior by providing an AlternateRouteProcessor
implementation to FerrostarCore
.
Refer to the demo apps examples of custom implementations.
Note that you can disable the default behavior of attempting to reroute
by setting an empty implementation (not setting the property to nil
/ null
!).
Customizing prompt timings
While most APIs don’t offer customizable timing for banners and spoken prompts,
you can edit the standardized Route
responses directly!
A functional map
operation drilling down to the instructions
is an elegant way to change trigger distances.
Configuring Navigation Behavior
Not all navigation experiences should behave the same, so Ferrostar lets you customize many important aspects of navigation.
These options are surfaced when calling startNavigation
on most platforms.
The higher-level platform interfaces wrap NavigationControllerConfig
in the Rust core.
StepAdvanceMode
The step advance mode describes when a maneuver is “complete” and navigation should advance to the next step. We have a few built-in variants in the core, which you can find in the Rust documentation. The high-level platform wrappers also have this and should show in your IDE documentation panel.
If you want to build your own custom step advance logic,
set the StepAdvanceMode
to manual,
and observe the TripState
in your application code.
Then, you can manually call advanceToNextStep
on the NavigationController
.
RouteDeviationTracking
This determines when the user is off the route. Certain applications (pedestrian navigation, for example) may want to disable this.
If the built-in deviation tracking options aren’t enough
(for example, if you want to do local map matching),
you can decide this yourself by implementing the RouteDeviationDetector
interface.
PRs are welcome for improvements or new general-purpose behaviors. You can also implement the interfaces directly in your Swift or Kotlin code! Here are some trivial examples.
Swift:
let config = SwiftNavigationControllerConfig(
stepAdvance: .relativeLineStringDistance(minimumHorizontalAccuracy: 16, automaticAdvanceDistance: 16),
routeDeviationTracking: .custom(detector: { _, _, _ in
// Pretend that the user is always off route
.offRoute(deviationFromRouteLine: 42)
}),
snappedLocationCourseFiltering: .raw
)
try core.startNavigation(route: route, config: config)
Kotlin:
val config = NavigationControllerConfig(
stepAdvance = StepAdvanceMode.RelativeLineStringDistance(16U, 16U),
routeDeviationTracking =
RouteDeviationTracking.Custom(
detector =
object : RouteDeviationDetector {
override fun checkRouteDeviation(
location: UserLocation,
route: Route,
currentRouteStep: RouteStep
): RouteDeviation {
// Pretend that the user is always off route
return RouteDeviation.OffRoute(42.0)
}
}),
CourseFiltering.RAW)
core.startNavigation(route, config)
Recalculation
NOTE: This section is currently specific to Swift and Kotlin. The Rust core does not expose any primitives for handling recalculation; this is currently at the platform level, and is not yet implemented for web.
The default behavior on supported platforms is to recalculate whenever the core determines that the user is off the route. Skip to the next section if the default behavior works for you.
If you want to do something more advanced though, you can! In Ferrostar, determining whether the user is off route and whether to recalculate the route are two separate concerns. Keep this in mind when writing your custom deviation detector. For example, if you want to display a flashing red overlay but not recalculate immediately, you could immediately report the user as off route, but delay recalculation.
Interfaces for signaling when to recalculate
To reflect these separate responsibilities,
you can set a delegate (FerrostarCoreDelegate
) on iOS
or RouteDeviationHandler
on Android.
This lets you tell the core what corrective action (if any)
to take when the user deviates from the route.
To initiate recalculation, return an appropriate CorrectiveAction
.
The higher-level platform layer will automatically handle the details
of making a new route request (ex: over HTTP),
ensuring that multiple parallel requests are not sent while waiting for a response,
and so on.
Your decision as an implementer of the interface is easy and narrowly defined;
the platform takes care of the rest unless you’re using the core directly
without a high-level wrapper.
Interfaces for handling alternative routes
Closely related to recalculation due to going off route is alternative route handling. This can occur either because you missed a turn and went off the route, or for other reasons like live traffic info suggesting that the current route is no longer optimal. Both scenarios are handled via alternative route hooks.
As usual, Ferrostar tries to have sensible defaults.
Considering the recalculation case,
if you don’t specify custom behavior,
the platform layer (again, currently iOS and Android only)
will automatically start a new navigation session
with the first route it receives after recalculation.
As a sanity check,
this behavior only triggers if the user is still off-course
(if the user went back on track in the interim, nothing happens).
If you want to customize this behavior,
set a FerrostarCoreDelegate
on iOS or an AlternativeRouteProcessor
on Android.
So, what about other cases besides recalculation? We envision live traffic, incidents, etc. being used to feed periodic “route revalidation” in the future. The same “alternative route” notification mechanism can be extended (ex: with a reason why the alternative is being supplied) for this purpose.
Route Providers
Route providers expose common interfaces for making requests to a routing engine and getting the data back in a standardized format for navigation. This layer of indirection makes Ferrostar extremely extensible.
NOTE: Extensible route providers are currently available on iOS and Android. The JavaScript platform presents some unique challenges, so only Valhalla backends are supported directly from the JavaScript API and published web components. Contributions and discussion around the best ways to enable this are very much welcome.
RouteProvider
There are two types of route providers:
one more suited to HTTP servers (RouteAdapter
),
and another designed for other use cases like local route generation (CustomRouteProvider
).
The core ships with common implementations,
but you’re free to define your own in your application code
without waiting for a PR to land or running a custom fork!
Route Adapters
A route adapter is the first class of route provider.
It is designed for HTTP, sockets, and other request/response flows.
A RouteAdapter
is typically nothing more than the composition of two halves:
a RouteRequestGenerator
and a RouteResponseParser
(both of which are traits at the Rust level;
protocols and interfaces at the platform level).
You can mix and match these freely
(though it probably goes without saying that you won’t get very far
swapping out the wrong parser for a server type).
Let’s illustrate why this architecture is nice with an example. Valhalla can generate responses in multiple formats. The default is its own JSON format, but it also has more compact Protobuf and (much) more verbose OSRM serializers.
The OSRM serializer is the one that’s typically used for navigation use cases,
so with a single RouteResponseParser
implementation,
we can parse responses from a Valhalla server or an OSRM server!
In fact, we can also parse responses from
the Mapbox Directions API or GraphHopper (in certain modes),
as they offer OSRM-compatible responses.
Every “extended OSRM” API includes additional data which are useful for navigation applications. The parser in the core of Ferrostar handles all of these “flavors” gracefully, and provides either direct or indirect support for most extensions. Of special note, the voice and banner instructions (popularized by Mapbox and supported in Valhalla) are always parsed, when available, and included in the route object. Annotations, which are available in some form or other on all OSRM-like APIs, can be parsed as anything. This leaves annotations open to extension, since it’s already used this way in practice.
OSRM parser in hand, all that we need to do to support these different APIs
is a RouteRequestGenerator
for each.
While all services mentioned are HTTP-based,
each has a different request format.
Writing a RouteRequestGenerator
is pretty easy though.
Request generators return a sum type (enum/sealed class)
indicating the type of request to make
and associated data like the URL, headers, and request body.
By splitting up the request generation,
request execution, and response parsing into distinct steps,
we reduce the work required to support a new API.
In this example, all we had to do was supply a RouteRequestGenerator
.
Our RouteAdapter
was able to use the existing OsrmResponseParser
,
and the core (ex: FerrostarCore
on iOS or Android)
used the platform native HTTP stack to execute the request on our behalf.
Here’s a sequence diagram illustrating the flow of data between components.
Don’t worry too much about the internal complexity;
you’ll only interface with FerrostarCore
at the boundary.
sequenceDiagram FerrostarCore->>+RouteAdapter: generateRequest RouteAdapter-->>+FerrostarCore: RouteRequest FerrostarCore-)Routing API: Network request Routing API--)FerrostarCore: Route response (bytes) FerrostarCore->>+RouteAdapter: parseResponse RouteAdapter->>+FerrostarCore: [Route] or error FerrostarCore->>+Application Code: [Route] or error
Bundled support
Ferrostar includes support for the following APIs out of the box.
Valhalla (Request + Response)
Valhalla APIs are supported out of the box with full request and response parsing.
As a bundled provider, FerrostarCore
in the platform layer exposes
convenience initializers which automatically configure the RouteAdapter
.
If you’re rolling your own integration or curious about implementation details,
the relevant Rust type is ValhallaHttpRequestGenerator
.
For response parsing, we use the OSRM format at this time.
You can construct an instance of ValhallaHttpRequestGenerator
directly
(if you’re using Rust for your application)
or using the convenience method createValhallaRequestGenerator
from Swift or Kotlin.
OSRM (Response only)
OSRM has become something of a de facto linga franca for navigation APIs. Ferrostar comes bundled with support for decoding OSRM responses, including the common extensions developed by Mapbox and offered by many Valhalla servers. This gives drop-in compatibility with a wide variety of backend APIs, including the hosted options from Stadia Maps and Mapbox, as well as many self-hosted servers.
The relevant Rust type is OsrmResponseParser
.
The autogenerated FFI bindings expose a createOsrmResponseParser
method
in case you want to roll your own RouteAdapter
for an API
that uses a different request format but returns OSRM format responses.
Implementing your own RouteAdapter
If you’re working with a routing engine and want to see it directly supported in Ferrostar, we welcome PRs! Have a look at the existing Valhalla (request generator) and OSRM (response parser) implementations for inspiration.
If you’d rather keep the logic to yourself (ex: for an internal API),
you can implement your own in Swift or Kotlin.
Just implement/conform to one or both of
RouteRequestGenerator
and RouteResponseParser
in your Swift and Kotlin code.
You may only need to implement one half or the other.
For example, to integrate with an API that returns OSRM responses
but has a different request format, you only need a RouteRequestGenerator
;
you can re-use the OSRM response parser.
Refer to the core test code on GitHub for examples which mock both halves. TODO: Examples here after 1.0.
CustomRouteProvider
Custom route providers are most commonly used for local route generation, but they can be used for just about anything. Rather than imposing a clean (but rigid) request+response model with opinionated use cases like HTTP in mind, the custom route provider is just a single-method interface (SAM for you Java or Kotlin devs) for getting routes asynchronously.
Here’s a sequence diagram illustrating the flow of data between components
when using a CustomRouteProvider
.
sequenceDiagram FerrostarCore-)CustomRouteProvider: getRoutes CustomRouteProvider--)FerrostarCore: [Route] or error
Using a RouteProvider
The parts of Ferrostar concerned with routing are managed
by the FerrostarCore
class in the platform layer.
After you create a RouteProvider
and pass it off to FerrostarCore
(or use a convenience constructor which does it all for you!),
you’re done.
Developers do not need to interact with the RouteProvider
after construction.
FerrostarCore
provides a high-level interface for getting routes,
which is as close as you usually get to them.
sequenceDiagram Your Application-)FerrostarCore: getRoutes FerrostarCore-)RouteProvider: (Hidden complexity) RouteProvider--)FerrostarCore: [Route] or error FerrostarCore--)Your Application: [Route] or error
This part of the platform layer follows the Hollywood principle. This provides elegant ways of configuring rerouting, which we cover in Configuring the Navigation Controller.
Location Providers
Location providers do what you would expect: provide locations! Location providers are included in the platform libraries, since they need to talk to the outside world.
Location can come from a variety of sources.
If you're somewhat experienced building mobile apps,
you may think of CLLocationManager
on iOS, or the FusedLocationProviderClient
on Android.
In addition to the usual platform location services APIs,
location can also come from a simulation or a third-party location SDK such as Naurt.
To support this variety of use cases,
Ferrostar introduces the LocationProvider
protocol (iOS) / interface (Android) as a common abstraction.
We bundle a few implementations to get you started, or you can create your own.
SimulatedLocationProvider
The SimulatedLocationProvider
allows for simulating location within Ferrostar,
without needing GPX files or complicated environment setup.
The usage patterns are roughly the same on iOS and Android:
- Create an instance of the
SimulatedLocationProvider
class. - Set a location or a
Route
. - Let the
FerrostarCore
handle the rest.
To simulate an entire route from start to finish,
use the higher level setSimulatedRoute
function to preload an entire route,
which will be "played back" automatically when there is a listener attached.
You can control the simulation speed by setting the warpFactor
property.
NOTE: While the SimulatedLocationProvider
is defined in the platform library layer,
the simulation functionality comes from the functional core and is implemented in Rust
for better testability and guaranteed consistency across platforms.
If you want low-level control instead, you can just set properties like lastLocation
and lastHeading
directly.
You can grab a location manually (ex: to fetch a route; both iOS and Android provide observation capabilities).
FerrostarCore
also automatically subscribes to location updates during navigation,
and unsubscribes itself (Android) or stops location updates automatically (iOS) to conserve battery power.
"Live" Location Providers
Ferrostar includes the following live location providers:
- iOS
CoreLocationProvider
- Location backed by aCLLocationManager
. See the iOS tutorial for a usage example.
- Android
- [
AndroidSystemLocationProvider
] - Location backed by anandroid.location.LocationManger
(the class that is included in AOSP). See the Android tutorial for a usage example. - [
FusedLocationProvider
] - Location backed by a Google Play ServicesFusedLocationClient
, which is proprietary but often provides better location updates. See the Android tutorial for a usage example.
- [
Implementation note: StaticLocationEngine
If you dig around the FerrostarMapLibreUI modules, you may come across as StaticLocationEngine
.
The static location engine exists to bridge between Ferrostar location providers and MapLibre.
MapLibre uses LocationEngine
objects, not platform-native location clients, as its first line.
This is smart, since it makes MapLibre generic enough to support location from other sources.
For Ferrostar, it enables us to account for things like snapping, simulated routes, etc.
The easiest way to hide all that complexity from LocationProvider
implementors
is to introduce the StaticLocationEngine
with a simple interface to set location.
This is mostly transparent to developers using Ferrostar, but in case you come across it, hopefully this note explains the purpose.
UI customization with SwiftUI
The iOS tutorial gets you set up with a “batteries included” UI and sane defaults, but this doesn’t work for every use case. This page walks you through the ways to customize the SwiftUI frontend to your liking.
Customizing the map
Ferrostar includes a map view built with the MapLibre SwiftUI DSL. This is designed to be fairly configurable, so if the existing customizations don’t work for you, we’d love to hear why via an issue on GitHub!
In the case that you want complete control though, the provided wrappers around map view are not that complex.
TODO: Docs on how to build your own navigation views + describe the current overlay layers.
The demo app is designed to be instructive in showing many available options, so be sure to look at that to build intuition.
Style
You can pass a style URL to any of the navigation map view constructors. You can vary this dynamically as your app theme changes (ex: in dark mode).
Camera
The camera supports two-way manipulation via SwiftUI bindings. TODO: more documentation
Adding map layers
You can add your own overlays too!
The makeMapContent
closure argument of the various map and navigation views
enables you to add more layers.
See the demo app for an example, where we add a little dot showing the raw location
in addition to the puck, which snaps to the route line.
Customizing the instruction banners
Ferrostar includes a number of views related to instruction banners. These are composed together to provide sensible defaults, but you can customize a number of things.
Distance formatting
By default, banners and other UI elements involving distance will be formatted using an MKDistanceFormatter
.
This should “just work” for most cases as it is aware of the device’s locale.
However, you can customize the formatting by passing in any arbitrary Formatter
.
This can be your own specially configured MKDistanceFormatter
or a custom subclass
which formats things to your liking.
Banner instruction views
The InstructionsView
is shipped with Ferrostar is the default banner view.
It uses the public domain directions iconography from Mapbox in a standard layout.
This view is an excellent example of composability, and is comprised of several subviews.
The units of the InstructionsView
are controlled using the formatter settings
you passed to the NavigationMapView
(if you’re using it).
If you’re not using the NavigationMapView
, you can pass a formatter directly.
You can also build your own custom banners using the provided components, or start from scratch.
TODO: Expose a view builder argument so that users can easily swap for their own view.
Customizing the navigation view grid
The batteries included UI has a lot of widgets, like zoom buttons and a status bar showing your ETA.
This grid is completely customizable! You can add, move, or replace widgets from the defaults.
Refer to the CustomizableNavigatingInnerGridView
public protocol and extension.
The PortraitNavigationOverlayView
and LandscapeNavigationOverlayView
are complete overlay configuration examples.
Specifically, they are the ones used by default (in the DynamicallyOrientingNavigationView
).
With this context, you should be able to see how the default views are composed
from others,
and design your own custom overlay configuration,
mixing the views provided in FerrostarMapLibre
with your own!
Customizing the high-level navigation views
The DynamicallyOrientingNavigationView
on both platforms also has a high level of flexibility via both constructor arguments and
(for SwiftUI) view modifiers.
We won't make an exhaustive list here,
but have a look at the documentation / code,
or explore in your IDE.
A few non-obvious notes for SwiftUI users are in order.
First, if you want to display speed limits,
make sure you use the navigationSpeedLimit
view modifier
on your navigation view.
We may add a default in the future,
but which signage to use is somewhat tricky and perhaps app-specific.
Second, road names are included from your routes by default. You can change the styling with view modifiers, or replace it with your own view like so:
// DynamicallyOrientingNavigationView(...)
.navigationCurrentRoadView(currentRoadNameViewBuilder: {
// TODO: Your view here, based on state...
EmptyView()
})
If you want to disable road names completely,
you can return EmptyView()
as shown above.
UI customization with Jetpack Compose
The tutorial get you set up with defaults using a “batteries included” UI, but realistically this doesn’t work for every use case. This page walks you through the ways to customize the Compose UI to your liking.
Note that this section is very much WIP.
Customizing the map
Ferrostar includes a NavigationMapView
based on MapLibre Native.
This is configurable with a number of constructor parameters.
If the existing customizations don’t work for you,
first we’d love to hear why via an issue on GitHub!
In the case that you want complete control though,
the map view itself is actually not that complex.
Style
The demo app uses the MapLibre demo tiles, but you’ll need a proper basemap eventually. Just pass in the URL of any MapLibre-compatible JSON style. See the vendors page for some ideas.
Camera
TODO: Docs on how this works.
Adding map layers
You can add your own overlays to the map as well (any class, including DynamicallyOrientingNavigationView
)!
The content
closure argument lets you add more layers.
See the demo app for an example.
Customizing the instruction banners
Ferrostar includes a number of views related to instruction banners. These are composed together to provide sensible defaults, but you can customize a number of things.
Distance formatting
By default, banners and other UI elements involving distance will be formatted using the bundled com.stadiamaps.ferrostar.composeui.LocalizedDistanceFormatter
.
Distance formatting is a complex topic though, and there are ways it can go wrong.
The Android ecosystem unfortunately does not include a generalized distance formatter, so we have to roll our own. Java locale does not directly specify which units should be preferred for a class of measurement.
We attempt to infer the correct measurement system to use, using some newer Android APIs. Unfortunately, Android doesn’t really have a facility for specifying measurement system independently of the language and region setting. So, we allow overrides in our default implementation.
If this isn’t enough, you can implement your own formatter
by implementing the com.stadiamaps.ferrostar.composeui.DistanceFormatter
interface.
If you find an edge case, please file a bug report (and PR if possible)!
Banner instruction composables
The com.stadiamaps.ferrostar.composeui.views.InstructionsView
composable
comes with sensible defaults, with plenty of override hooks.
The default behavior is to use Mapbox’s public domain iconography,
format distances using the device’s locale preferences,
and use a color scheme and typography based on the Material theme.
You can pass a customized distance formatter as noted above, and you can also override the theme directly if you’d like more control than our defaults derived from the Material theme.
Finally, you can override the leading edge content. Just write your own composable content block rather than accept the default.
If you need even more control, you can use the com.stadiamaps.ferrostar.composeui.views.maneuver.ManeuverInstructionView
directly,
or write your own, optionally using the ManeuverImage
.
Android Foreground Service
Ferrostar provides an Android foreground service that runs automatically during a trip. Such a service is required to comply with Google's Foreground Location Requirements.
Overview
The Kotlin ForegroundServiceManager
links FerrostarCore
to the foreground service FerrostarForegroundService
using a binder.
This technique allows us to bind the foreground service to a trip operated by the core.
When navigation starts, the foreground service is started and bound automatically.
Similarly, when we stop navigating, the core will stop the foreground and unbind it.
Note: you must call stop on Ferrostar core to stop the notification. It does not automatically close out when the user arrives because location updates are not stopped.
Setting Up Permissions
This feature requires adding the following to your app or module AndroidManifest.xml
.
<!-- Foreground service permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application>
<service
android:name="com.stadiamaps.ferrostar.core.service.FerrostarForegroundService"
android:foregroundServiceType="location"
/>
</application>
Your app must still request POST_NOTIFICATIONS
and FOREGROUND_SERVICE_LOCATION
permissions on API 34+ (Upside Down Cake).
See the demo app's call to request these permissions in your composable app.
val allPermissions =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.FOREGROUND_SERVICE_LOCATION)
} else {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
}
val permissionsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
// TODO: Handle permission fialures.
}
Using the Service Manager with FerrostarCore
In your app module, you'll need to pass in the ForegroundServiceManager
as a parameter to construct FerrostarCore
.
Here's how to do that with the included notification builder.
val foregroundServiceManager: ForegroundServiceManager = FerrostarForegroundServiceManager(appContext, DefaultForegroundNotificationBuilder(appContext))
val core =
FerrostarCore(
...,
foregroundServiceManager,
...)
The demo app shows this using a lazy initializer.
TODO: link the dependency injection example here once we have a doc for that.
Customization
You can provide your own implementation of the ForegroundNotificationBuilder
to customize the notification the foreground service publishes to users.
To accomplish this, simply create a new Notification
like the DefaultForegroundNotificationBuilder
that implements the abstract ForegroundNotificationBuilder
.
Your class needs to create and build the foreground notification. This can include setting any required pending intents (e.g. openPendingIntent
),
portraying relevant information about the service to the user and so on.
class MyForegroundNotificationBuilder(
context: Context
) : ForegroundNotificationBuilder(context) {
override fun build(tripState: TripState?): Notification {
if (channelId == null) {
throw IllegalStateException("channelId must be set before building the notification.")
}
// Generate the notification builder. Note that channelId is set on newer versions of Android.
// The channel is used to associate the notification in settings with the channel's title. This allows
// a user to better understand the notification they're being presented in the android settings app.
val builder: Notification.Builder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, channelId)
} else {
Notification.Builder(context)
}
// TODO: Build your notification off of the TripState here.
// Set the open app pending intent. This example shows the case where the user tapping the notification will
// open the app.
builder.setContentIntent(openPendingIntent)
// You can also use the provided `stopPendingIntent` which like Google Maps, Mapbox and others allows the user
// to directly stop the navigation trip (and location updates) from the notification.
return builder.build()
}
}
When initializing the manager, all you need to do is change out the notification builder to your custom one.
val foregroundServiceManager: ForegroundServiceManager = FerrostarForegroundServiceManager(appContext, MyForegroundNotificationBuilder(appContext))
Learn More
Web
The web tutorial gets you set up with a “batteries included” UI and sane defaults (if a bit customized for Stadia Maps at the moment). This document covers ways you can customize it to your needs.
Removing or replacing the integrated search box
If you want to use your own code to handle navigation instead of the integrated search box, you can do so by the following steps:
Disable the integrated search box
You can disable the integrated search box with the useIntegratedSearchBox
attribute.
<ferrostar-core
id="core"
valhallaEndpointUrl="https://api.stadiamaps.com/route/v1"
styleUrl="https://tiles.stadiamaps.com/styles/outdoors.json"
profile="bicycle"
useIntegratedSearchBox="false"
></ferrostar-core>
Use your own search box/geocoding API
The HTML, JS, and CSS for this is out of scope of this guide, but here’s an example (without UI) showing how to retrieve the latitude and longitude of a destination using the Nominatim API (note their usage policy before deploying):
const destination = "One Apple Park Way";
const { lat, lon } = await fetch("https://nominatim.openstreetmap.org/search?q=" + destination + "&format=json")
.then((response) => response.json())
.then((data) => data[0]);
Get routes manually
Once you have your waypoint(s) geocoded, create a list of them like this:
const waypoints = [{ coordinate: { lat: parseFloat(lat), lng: parseFloat(lon) }, kind: "Break" }];
The asynchronous getRoutes
method on FerrostarCore
will fetch routes from your route provider (ex: a Valhalla server).
Here’s an example:
const core = document.getElementById("core");
const routes = await core.getRoutes(locationProvider.lastLocation, waypoints);
const route = routes[0];
Starting navigation manually
Once you have a route, it’s time to start navigating!
core.startNavigation(route, config);
Location providers
The “batteries include” defaults will use the web Geolocation API automatically. However, you can override this for simulation purposes.
BrowserLocationProvider
BrowserLocationProvider
is a location provider that uses the browser's geolocation API.
// Request location permission and start location updates
const locationProvider = new BrowserLocationProvider();
locationProvider.requestPermission();
locationProvider.start();
// TODO: This approach is not ideal, any better way to wait for the locationProvider to acquire the first location?
while (!locationProvider.lastLocation) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
SimulatedLocationProvider
TODO: Documentation
Contributing to Ferrostar
We're stoked that you're interested in working on Ferrostar! This contribution guide will get you started developing in no time, and provides some guidelines to follow when submitting an issue or PR.
How we communicate
It is a good idea to discuss large proposed changes before proceeding to an issue ticket or PR. The maintainers and active contributors use the following forums:
- For informal chat discussions, visit the
#ferrostar
channel in the OSMUS Slack. You can get an invite to the workspace at slack.openstreetmap.us. - For larger discussions where it would be desirable to have wider input / a less ephemeral record, you can open an issue or discussion on GitHub.
Issues vs Discussions?
If you have a pretty clear feature request or bug report, just open a GitHub Issue.
If it’s a bit more open-ended, consider GitHub Discussions first.
Discussions are organized into two categories: engineering RFDs and Q&A. Q&A should be self-explanatory; if you have a question about something, need help with an integration, etc., then post it here! Engineering RFDs are designed to start a discussion about the best way to do something, share research, and discuss larger projects before the issue stage. If you’re curious what an RFD is, check out this document from Oxide Computer Company
Pull Request Tips
To speed up reviews, it's helpful if you enable edits from maintainers when opening the PR. In the case of minor changes, formatting, or style nitpicks, we can make edits directly to avoid wasting your time. In order to enable edits from maintainers, you'll need to make the PR from a fork owned by an individual, not an organization. GitHub org-owned forks lack this flexibility.
If your change is visual in nature and isn’t covered by snapshot tests, before+after screenshots or videos are greatly appreciated!
Developer Environment Setup
To ensure that everything can be developed properly in parallel, we use a monorepo structure. This, combined with CI, will ensure that changes in the core must be immediately reflected in platform code like Apple and Android.
Let's look at what's involved to get hacking on each platform.
Rust
- Install Rust.
If at all possible, install
rustup
. We use rust-toolchain.yml to synchronize the toolchain and install targets automatically (otherwise you will need to manage toolchains manually). - Open the cargo workspace (
common/
) in your preferred editing environment.
The Rust project is a cargo workspace, and nothing beyond the above should be needed to start hacking! Make some changes and run the tests!
PR checklist
Before pushing, run the following from the common
folder:
- Run
cargo fmt
to automatically format any new rust code. - Bump the version on the ferrostar Cargo.toml at
common/ferrostar/Cargo.toml
(if necessary). If you forget to do this and make breaking changes, CI will let you know.
Web
Perform all commands unless otherwise noted from the web
directory.
- Install
wasm-pack
:
cargo install wasm-pack
- Build the WASM package for the core:
npm run prepare:core
- Install dependencies:
npm install
- Run a local dev server or do a release build:
# This will start a local web server for the demo app with hot reload
npm run dev
# Or you can do a release build (we test this in CI)
npm run build
PR checklist
Run npm run format:fix
from the web
directory before committing
to ensure consistent formatting.
iOS
- Install the latest version of Xcode.
- Install the Xcode Command Line Tools.
xcode-select --install
- Install
swiftformat
. - Since you're developing locally, set
let useLocalFramework = true
inPackage.swift
. (TODO: Figure out a way to extract this so it doesn't get accidentally committed.) - Run the iOS build script:
cd common
./build-ios.sh
IMPORTANT: every time you make changes to the common core,
you will need to run build-ios.sh
to see your changes on iOS!
We want to integrate this into the Xcode build flow in the future,
but at the time of this writing,
it is not possible with the Swift package flow.
Further, the "normal" Xcode build flow always assumes xcframeworks
can't change during build,
so it processes them before any other build rules.
Given these limitations, we opted for a shell script until further notice.
- Open the Swift package in Xcode. (NOTE: Due to the quirks of how SPM is designed, Package.swift must live in the repo root. This makes the project view in Xcode slightly more cluttered, but there isn't much we can do about this given how SPM works.)
PR checklist
Run swiftformat .
from the apple
directory before committing
to ensure consistent formatting.
Android
- Install Android Studio (NOTE: We assume you are using a recent version no more than ~a month out of date).
- Install cargo-ndk to allow gradle to build the local library
libferrostar.so
andlibuniffi_ferrostar.so
. With cargo-ndk installed you can load and sync Android Studio then build the demo app allowing gradle to automatically build what it needs.
cargo install cargo-ndk
- Ensure that the latest NDK is installed
(refer to the
ndkVersion
number incore/build.gradle
and ensure you have the same version available). This is easiest to install via Android Studio's SDK Manager (under SDK Tools > NDK). - Open the Gradle workspace ('android/') in Android Studio. Gradle builds automatically ensure the core is built, so there are no funky scripts needed as on iOS.
- (Optional) If you want to use Maven local publishing to test...
- Bump the version number to a
SNAPSHOT
inbuild.gradle
. - run
./gradlew publishToMavenLocal -Pskip.signing
. - Reference the updated version number in the project, and ensure that
mavenLocal
is one of therepositories
.
- Bump the version number to a
PR checklist
Run the ktfmtFormat
gradle action before committing to ensure consistent formatting.
We use Paparazzi for UI snapshot tests efficiently (without a full emulator).
You can run these locally with ./gradlew verifyPaparazziDebug
.
You can record updated snapshots with ./gradlew recordPaparazziDebug
.
Testing
We employ a mix of testing tools and methodologies across the Ferrostar stack. We use a mix of unit, integration, snapshot, and property testing. When possible, please include tests in your PRs. If you can employ multiple strategies (ex: unit testing + property testing), please do!
Tests are automatically run as part of CI.
Types of tests
Unit tests typically verify that some function gives an expected output for some known inputs. This is great for verifying specific properties of pure functions. For example, checking that 1+1 = 2. However, just because you checked a few examples doesn’t tell us that the code is correct. Despite this limitation, unit tests are a great tool, and are very fast to run. Add them where possible, as they complement other strategies.
Property testing lets you specify some random variables (possibly with limits; ex: floating point numbers between -180 and 180) rather than static inputs. This lets you test invariants rather than specific cases. For example, asserting that any integer plus zero equals itself. Property testing is a great fit for highly “algorithmic” code, and we use it extensively.
Snapshot testing executes some code and then takes a “snapshot” of the state. This can be applied to UI code, where an image snapshot of a view is saved after rendering. We use this extensively for ensuring that overlays render correctly with static inputs, for example. It can also be applied to arbitrary data structures. We use snapshot tests to test things like “given a fixed route and a stream of GPS updates, what do all the intermediate state transitions look like?”
Finally, integration testing is similar to unit testing in that the inputs are static. However, it’s designed to test much more of a system “end to end” whereas unit tests are usually targeted at a single function.
All of the approaches above combine to give us great confidence in the correctness of Ferrostar’s core business logic, and let us refactor without fear of breakage.
Let’s look at the specific tooling for each platform.
Rust
On Rust, we employ both unit and integration tests using the standard cargo tooling.
In addition, we employ both property testing via proptest
and snapshot testing via insta
.
There is nothing special about running unit, integration, and property tests;
just run cargo test
!
For snapshot tests, when a snapshot does not exist (new test)
or the test output doesn’t match the stored snapshot,
you’ll get a test error.
You can review the changes using cargo insta review
,
a CLI tool which shows a colorized diff and prompts whether to update the snapshot.
Snapshot files are committed to the repo and will show in the PR diffs.
iOS
iOS testing is best done in Xcode, but you can also use the terminal (see the github actions for commands we run). Open up Xcode and press cmd+u to run tests.
Most of the tests are just regular XCUnit tests that you’re already used to.
Of note, we include some snapshot testing via macros
which snapshot the views.
When adding a new snapshot test or changing a view,
you’ll get an error.
Modify the snapshot assertion function to include the keyword argument
record: true
to update the snapshot.
This will be committed to git and visible in diffs.
Android
On Android, we use a mix of standard JUnit unit tests (fast),
snapshot tests with Paparazzi (pretty fast),
and connected checks (SLOW).
You can invoke tests with ./gradlew test
,
./gradlew verifyPaparazziDebug
,
and ./gradlew connectedCheck
respectively.
To record snapshots for a new test or update old ones,
run ./gradlew recordPaparazziDebug
.
Web
TBD
Architecture Overview
Ferrostar is organized in a modular (hexagonal) architecture. At the center sits the core, which is more or less purely functional (no mutable state). This is great for testability and portability. In the extreme on the portability side, we Ferrostar should build for WASM and many embedded architectures, though no frontends currently exist.
One level up is a binding layer. This is generated using UniFFI. These bindings are usually quite idiomatic, taking advantage of language-specific features like enums, data classes, and async support.
Platform libraries add higher level abstractions, state management, UI, and access to platform APIs like device sensors. As much as possible, we try to keep the API similar across platforms, but occasional divergences will pop up here to accommodate platform norms (ex: a single "core delegate" in Swift vs multiple interfaces in Kotlin).
Breaking down the responsibilities by layer:
- Core (Rust)
- Common data models (standard definitions for what a “route” is, for example)
- Request generation and response parsing for APIs (ex: Valhalla and OSRM)
- Spatial algorithms like line snapping and distance calculations
- Navigation state machine (which step are we on? should we advance to the next one? etc.)
- Bindings (UniFFI; Swift and Kotlin)
- Auto-generated direct bindings to the core (models, navigation state machine, etc.)
- Platform code (Swift and Kotlin)
- Higher-level imperative wrappers
- Platform-native UI (SwiftUI / Jetpack Compose + MapLibre)
- Interface with device sensors and other platform APIs
- Networking
As in any hexagonal architecture, you can't skip across multiple layer boundaries.
Commercial Vendors
Here is a list of commercial vendors with APIs that are currently supported by Ferrostar. PRs welcome to expand the list or to add support!
Routing
- Stadia Maps
- Global hosted Valhalla API (free keys for evaluation available)
- Custom deployments with proprietary data integrations possible
- GIS • OPS
- Routing specialists, with an emphasis on Valhalla
- Specializes in custom deployments and integration of proprietary data
Basemaps
You need basemaps for most (but not all!) useful navigation apps.
The included UI components are all based on MapLibre, which supports the Mapbox Vector Tile specification. Here are a few popular vendors:
- Stadia Maps
- Mapbox (note that pricing is different for accessing tiles directly rather than using their proprietary SDKs)
- Jawg
- MapTiler
At the time of this writing, all basemap vendors offer free API keys for evaluation use.