Like a bird


In the current episode, I will continue discussing maplibre and its features, as well as introducing some new terms. Additionally, you’ll learn which tram you should take to get to my neighborhood if you ever visit the city where I live 🤣. As always, I am a proponent of practice over theory, so you can expect some interesting examples.

If you have read my series titled Build you own planet with OSM you should now already that vector tiles are core element for building modern web maps. But of course, there are several other ways to add data to the map. I’d like to discuss one of them with you - GeoJSON. GeoJSON (as you might suspect, it is based on the JSON format) allows for a straightforward representation of geographic features along with their non-spatial attributes. Its undeniable advantage is simplicity, readability, and ease of transfer between different environments. Following geometries can be described via GeoJSON: points, line strings, polygons and multi-part collections of these types. An example file including the above-mentioned geometries can be found below:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [1.0, 2.0]
      },
      "properties": {
        "name": "bus stop"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [1.0, 0.0],
          [2.3, 1.0],
          [4.0, -2.0]
        ]
      },
      "properties": {
        "streetName": "Krzywoustego"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [10.0, 0.0],
            [5.0, 10.0],
            [0.0, 0.0],
            [10.0, 0.0]
          ]
        ]
      },
      "properties": {
        "name": "Neverland",
        "population": 100000
      }
    }
  ]
}

As you can see each feature contains 3 properties: type - constant string feature, geometry (always contains type and coordinates) and properties (JSON like object of non-spatial attributes). To understand multi-part geometries you should visit wikipedia (they have nice visual examples), but basically it’s nothing more like combining independent parts into single feature (e.g. it will allow to describe single feature containing multiple polygons). Practical example of GeoJSON ? Here you will find the public transportation website in my city. Next to timetables they expose map view for each bus/tram line. Tram number 8 is the most commonly used means of public transportation for me. Its timetable can be found here and map view here. A little bit of sniffing in chrome developer console and we can see that they use GeoJSON for drawing route on a map. For reason I don’t understand now (😅😅😅) we have to use POST method for calling their API, instead usuall GET. Let’s use curl then.

curl -X POST https://www.zditm.szczecin.pl/pl/passenger/maps/trajectories/8 -o tram8.json
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 84751  100 84751    0     0   417k      0 --:--:-- --:--:-- --:--:--  424k

Now we can open tram8.json file in any code editor. They’ve used LineString to describe tram route, which seems to be a good choice. Additionally I can see 2 non-spatial attributes, which are: route_variant_number and route_variant_type. For now its purpose is unknown for me. Also I want to know city boundaries. For this purpose I’m using nominatim - open source geocoder. We can use free form query (it’s also possible to use reverse geocoding with random point inside), as - Szczecin (my hometown) - is seventh largest city in Poland, so there will be no problems to find it. Based on documentation I’m forming pertinent query string - I want to get response as GeoJSON.

curl -X GET https://nominatim.openstreetmap.org/search?q=Szczecin&format=geojson&limit=1&polygon_geojson=1 -o szczecin.json

I’m getting result which I expected - city boundaries, described as Polygon. I’m keeping both files for later use. Now let’s prepare some vector godies for our map. A little look on google maps and I’m proposing following cropping area (left, top, right, bottom): 14.4,53.5,14.8,53.3. For creating tiles I’m using bash script which I’ve prepared earlier. Let’s run the script with following arguments:

./make-tiles.sh --bbox 14.4,53.5,14.8,53.3 --output szczecin --dump ./OSM/planet-230918.osm.pbf

Just a reminder: you have to pass all 3 options: bbox, output (file name without extension) and dump (location of Planet dump on your computer). After awhile we’re getting szczecin.mbtiles. In normal circumstances we serve those tiles via tileserver-gl. But today we’ll use different approach: pmtiles. It’s pretty new standard for storing tiled data - single-file archieve which can be stored on a commodity storage, like S3. It makes our web apps “serveless” - free of external tile backend (like tileserve-gl), which benefits in low-cost, zero-maintenance app. Project is driven by Protomaps. Conversion from mbtiles into

pmtiles is extremely easy. First, I’m downloading go binary from there. Assuming binary is stored in same location as mbtiles we do the conversion:

pmtiles convert szczecin.mbtiles szczecin.pmtiles

I’m keeping pmtiles in git repository so that it can be hosted via gitub pages. Last but not least we define map style. Instead of creating a style from scratch, I’m customizing an open-source map style - positron-gl (light theme with big potential for custom visuzalizations, my favorite one ♥️♥️♥️). I’m removing all symbol based layers cause there is no need for them. Secondly I’m updating source property to point into pmtiles - I’m using relative url for them, knowing that they will be hosted via github pages. Please notice, that comparing to mbtiles we don’t define vector property, instead we use url.

"openmaptiles": {
  "type": "vector",
  "url": "pmtiles:///blog/assets/szczecin.pmtiles",
  "attribution": "© <a href='https://openstreetmap.org'>OpenStreetMap</a>",
  "minzoom": 0,
  "maxzoom": 14
}

Entire map style is accessible here. Now we have everything to start work on frontend side of our mini project. Instead of raw HTML + JS script I would like to introduce react-map-gl - collections of React components which wraps mapbox-gl compatibile libraries. We’ll also need pmtiles package and maplibre-gl. Example app boilerplate for such configuration looks like that:

import 'maplibre-gl/dist/maplibre-gl.css'

import React, { useEffect } from 'react'
import maplibre from 'maplibre-gl'
import Map from 'react-map-gl'
import * as pmtiles from 'pmtiles'

export default function MapComponent() {
  useEffect(() => {
    const protocol = new pmtiles.Protocol()
    maplibre.addProtocol('pmtiles', protocol.tile)
  }, [])

  return (
    <Map
      mapLib={maplibre}
      initialViewState={{ longitude: 14.6, latitude: 53.43, zoom: 9.5 }}
      minZoom={9}
      mapStyle="/blog/positron.json"
    />
  )
}

The mandatory step to support pmtiles is to register its protocol, which is done inside useEffect hook. Next we initialize a map with maplibre as mapLib props (we can also use mapbox-gl, but it requires to create an account on their page and generate a token), and mapStyle which I defined earlier. Also we are setting initial map conditions (latitude, longitude and zoom). In next step we will add 2 layers - one for tram route, and another for city boundaries. Both layers uses GeoJSON as a source (both files I downloaded earlier and they are hosted as static files via github pages). Inside React component I’m simply using fetch to get them. Each layer is wrapped in Source component, and both sources have to be children of Map component.

const [tramRoute, setTramRoute] = useState()
const [boundaries, setBoundaries] = useState()

useEffect(() => {
  Promise.all([
    fetch('/blog/assets/tram8.json'),
    fetch('/blog/assets/szczecin.json'),
  ])
    .then((responses) => Promise.all(responses.map((res) => res.json())))
    .then(([tramRoute, boundaries]) => {
      setTramRoute(tramRoute)
      setBoundaries(boundaries)
    })
    .catch(console.error)
}, [])

(...)

return (
  <Map
    mapLib={maplibre}
    initialViewState={{ longitude: 14.6, latitude: 53.43, zoom: 9.5 }}
    minZoom={9}
    mapStyle="/blog/assets/positron.json"
  >
    <Source id="route" type="geojson" data={tramData}>
      <Layer
        id="route"
        source="route"
        type="line"
        paint={{
          'line-color': [
            'case',
            ['==', ['get', 'route_variant_type'], 'default'],
            'rgb(98, 0, 238)',
            'rgb(153, 153, 153)',
          ],
          'line-opacity': 0.7,
          'line-width': [
            'case',
            ['==', ['get', 'route_variant_type'], 'default'],
            5,
            2,
          ],
        }}
      />
    </Source>
    <Source id="boundaries" type="geojson" data={boundaries}>
      <Layer
        id="boundaries"
        type="line"
        paint={{
          'line-color': 'rgb(140, 41, 49)',
          'line-dasharray': [6, 2],
          'line-width': 2,
        }}
      />
    </Source>
  </Map>
)

It looks somewhat familiar, doesn’t it? Layer component uses exactly same props as layers in map style. id and type are mandatory props, paint varies based on selected type. We don’t need to define source, as default one is their parent (Source component). Source props are also straightforward: id, type (geojson in our case) and data (we use local component state).

Last but not least, cherry on top - bird view. I want to implement it in such a way that the camera follows the route, as if it were tracking an invisible tram. And as always maplibre comes to our aid by exposing the flyTo function. To make animation smooth, we’ll fly between 2 consecutive points with some predefined interval. And because our route is already ordered (line string is just an array of points), we just need tiny modification of geojson form. I learned what the route_variant_type property is used for - if equals default - it means that the tram is following the default route, while other values indicate that the tram deviates, for example, to the depot. We filter those features with

default props, and map coorindanets into flat array. The final step is to correctly set the camera so that it gives the impression of following the real vehicle. I’m using @turf/bearing to calculate right angle.

Source code of enitre app, encapsulated into single React component, you can check here. However, you will find the complete interactive application below. Hit play button and enjoy your bird trip (also admire my city) 🚀🚀🚀

Huh, we did it again! Enormous portion of knowledge which results in beautiful interactive web app. And what’s best - thanks to pmtiles application does not require maintaining an additional server for serving veector tiles! As you can see with more mapping tools you know, more interesting possibilities you have. The best is still ahead of us. Stay tuned and see you soon!