Building an OpenStreetMap app in Rust, Part III

Tor Hovland
Bekk
Published in
6 min readFeb 22, 2021

--

In Part II of this series we got an initial Seed app up and running. In this part we’ll add a map and get the app deployed.

Photo by Tabea Damm on Unsplash

Leaflet

Showing a map on the web, that means Leaflet. But Leaflet is Javascript! What to do? Well, it doesn’t matter. As soon as the code we need is loaded into the browser, we can interoperate with it. And Markus Kohlhase has already wrapped Leaflet into a Rust crate for us. It’s nothing but a list of function bindings like this:

#[wasm_bindgen(constructor, js_namespace = L)]    
pub fn new(id: &str, options: &JsValue) -> Map;
#[wasm_bindgen(method)]
pub fn flyTo(this: &Map, latlng: &LatLng, zoom: f64);
#[wasm_bindgen(method, js_name = flyTo)]
pub fn flyToWithOptions(this: &Map, latlng: &LatLng, zoom: f64, options: &JsValue);

We’ll keep things simple and load Leaflet like it says on the box:

<html>
<head>
<link rel="stylesheet"
href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xod..." crossorigin=""/>
<link data-trunk rel="scss" href="src/styles/index.scss" />
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
integrity="sha512-XQo..." crossorigin="">
</script>
</head>
<body>
<section id="map"></section>
<section id="app">Loading ...</section>
</body>
</html>

As you can see, we’ve also made an HTML element with id="map" that we can load the map into using the following code:

fn init(_: Url, _: &mut impl Orders<Msg>) -> Model {
let map = leaflet::Map::new("map", &JsValue::NULL);
map.setView(&LatLng::new(63.5, 10.5), 5.0);
leaflet::TileLayer::new(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
&JsValue::NULL
).addTo(&map);
Model::default()
}

This should mostly look familiar if you’ve used Leaflet in Javascript. Although it works, I have cheated a little bit. You may have noticed from above that I put the map element directly into the HTML, as a sibling to the app element. If instead I let Seed render the map element, I get this error:

Uncaught (in promise) Error: Map container not found.
at i._initContainer (Map.js:1103)
at initialize (Map.js:136)

This is Leaflet telling us that it cannot find the map element. And the reason for that is that Seed hasn’t called the view() function yet. So we cannot keep the map initialization code in the init() function, we have to postpone it. At first I couldn’t find a way to have Seed submit a message after rendering, so the only solution I came up with was to use the browser’s MutationObserver to trigger a map init function as soon as the element got rendered. Getting this to work was illuminating, in both good and bad ways. Good, because it demonstrated how browser APIs for working with the DOM are just as available from Rust, through web-sys, as they are from Javascript. And bad, because the type checker and borrow checker in Rust pushed us into a corner of rather horrible-looking code. I won’t go into the details here, but feel free to look them up yourself. There are certainly ergonomic improvements to be made in this area!

Luckily, Martin, the creator of Seed, pointed out to me on the Seed Discord that there is indeed a simple way for Seed to let us know when it has rendered. It involves moving the map initialization into a separate function and registering it like this:

fn init(_: Url, orders: &mut impl Orders<Msg>) -> Model {
// Cannot initialize Leaflet until the map element has rendered.
orders.after_next_render(init_map);
Model::default()
}
fn init_map(_: RenderInfo) {
...
}

Much better!

We should make sure we can put some stuff on the map as well. But the Leaflet crate is only a version 0.1, and by no means does it include a complete set of bindings. It basically only lets us place markers, icons, and rectangles on the map. Luckily, adding more bindings is straightforward, and I’ve submitted a pull request that adds polylines, polygons, and circles. Until it (hopefully) gets merged (Update: It just got released 🎉), I can refer to my own fork in Cargo.toml:

leaflet = { git = "https://github.com/torhovland/leaflet-rs" }

I can now add a polyline like this:

use leaflet::{LatLng, Map, Polyline, TileLayer};#[derive(Serialize, Deserialize)]
struct PolylineOptions {
color: String,
}
Polyline::new_with_options([
LatLng::new(63.25, 11.25),
LatLng::new(63.75, 11.75),
LatLng::new(63.5, 12.0)
].iter().map(JsValue::from).collect(),
&JsValue::from_serde(&PolylineOptions {
color: "red".into()
}).expect("Unable to serialize polyline options")
).addTo(&map);

Regarding types, things have become slightly more involved. While wasm_bindgen handles primitive types just fine, you’ll quickly run into the need to deal with JsValue types. In the above code, that’s the case with the vector of coordinates as well as with the object with polyline options. In the latter case, we use the Serde serializer to help us construct the JsValue.

The current state of our little app.

Deployment

It may seem odd to discuss the deployment of such a measly app, but I like to get it set up as soon as I go beyond “Hello World”. For one thing, it’s nice to see the app take shape for real, but it’s also good to get early feedback in case of any build issues, and later, test failures.

As the code is already on GitHub, it makes perfect sense to use GitHub Actions for deployment, and GitHub Pages for hosting. Setting up GitHub Actions is as simple as adding a build recipe under .github/workflows. Here’s what we need to do:

on: push

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2

- name: Install Rust tools
uses: actions-rs/toolchain@v1
with:
profile: minimal
target: wasm32-unknown-unknown
toolchain: stable
- name: Install Trunk
uses: actions-rs/install@v0.1
with:
crate: trunk
version: latest
use-tool-cache: true

- name: Install wasm-bindgen-cli (needed by Trunk)
uses: actions-rs/install@v0.1
with:
crate: wasm-bindgen-cli
version: latest
use-tool-cache: true

- name: Build
run: trunk build

- name: Deploy to GitHub Pages
if: success()
uses: crazy-max/ghaction-github-pages@v2
with:
build_dir: dist
fqdn: surway.hovland.xyz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

This defines a job running on Ubuntu with 6 steps, and it should be easy to understand what each one does. The flexibility of GitHub Actions becomes apparent when you realize that all steps but one refer to build step definitions that are simply somewhere on GitHub. For example, actions-rs/toolchain@v1 is just the v1 tag of https://github.com/actions-rs/toolchain. This is true for the built-in steps as well. The code checkout step is defined at https://github.com/actions/checkout. The only step that’s not like this is the build step, which simply needs to run the trunk build command on the Ubuntu machine as soon as Trunk has been installed.

The final step is an instruction to take whatever is in the dist directory that Trunk has built, and check it in to a separate branch (gh-pages by default) in my repository. It will also set my custom domain name. The step needs my secret GitHub token to be able to push to the branch. GitHub Pages is set up to take whatever is in that branch and host it using the domain name. For this to work, I’ve had to set up a CNAME record at my domain hosting service that points the surway subdomain to torhovland.github.io. GitHub figures out the rest.

This deployment works, but building Trunk and wasm-bindgen-cli takes 12 minutes. 😲 Honestly, that doesn’t bother me all that much, but we can do better. GitHub Actions can run its steps on any Docker image. We just have to make one first. That’s pretty simple, all we need is a Dockerfile like this:

FROM rust:1.50RUN rustup target add wasm32-unknown-unknown && \
cargo install wasm-bindgen-cli && \
cargo install --locked trunk

Since I’ve pushed the resulting image to the Docker Hub, I can simplify the build recipe by a lot:

on: pushjobs:
deploy:
runs-on: ubuntu-latest
container: torhovland/rust-trunk:0.8.2
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Build
run: trunk build
- name: Deploy to GitHub Pages
...

And with that, build time improved from 14 minutes to less than two and a half minutes. 🚀

Next time, we’ll get started with downloading some OpenStreetMap data.

--

--