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 the DynamicallyOrientingNavigationView.

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 and BrowserLocationProvider 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:

  1. Create an instance of the SimulatedLocationProvider class.
  2. Set a location or a Route.
  3. 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 a CLLocationManager. See the iOS tutorial for a usage example.
  • Android
    • [AndroidSystemLocationProvider] - Location backed by an android.location.LocationManger (the class that is included in AOSP). See the Android tutorial for a usage example.
    • [FusedLocationProvider] - Location backed by a Google Play Services FusedLocationClient, 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.

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)!

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.

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:

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

  1. 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).
  2. 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.

  1. Install wasm-pack:
cargo install wasm-pack
  1. Build the WASM package for the core:
npm run prepare:core
  1. Install dependencies:
npm install
  1. 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

  1. Install the latest version of Xcode.
  2. Install the Xcode Command Line Tools.
xcode-select --install
  1. Install swiftformat.
  2. Since you're developing locally, set let useLocalFramework = true in Package.swift. (TODO: Figure out a way to extract this so it doesn't get accidentally committed.)
  3. 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.

  1. 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

  1. Install Android Studio (NOTE: We assume you are using a recent version no more than ~a month out of date).
  2. Install cargo-ndk to allow gradle to build the local library libferrostar.so and libuniffi_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
  1. Ensure that the latest NDK is installed (refer to the ndkVersion number in core/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).
  2. 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.
  3. (Optional) If you want to use Maven local publishing to test...
    • Bump the version number to a SNAPSHOT in build.gradle.
    • run ./gradlew publishToMavenLocal -Pskip.signing.
    • Reference the updated version number in the project, and ensure that mavenLocal is one of the repositories.

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.

The Ferrostar Architecture Diagram

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:

At the time of this writing, all basemap vendors offer free API keys for evaluation use.