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!