Mapping GTFS stops
This tutorial and its repository show how to generate a map, in the browser, that displays the location of transit stops which are stored in a PostgreSQL database. Here's a demo, it looks like this:
Let's get started!
0. Prerequisites
Start by making sure you have the prerequisites listed here, then fork our gtfs-app-setup repository and launch it with docker-compose up --build
.
If you already ran other projects using this setup, you might get a conflict error. Just clear the previous setup with
docker stop $(docker ps -q)
,docker system prune -a
, anddocker volume rm $(docker volume ls -q)
, then trydocker-compose up --build
again.
Just like that, you have:
- a NodeJS backend server
- a bare-bones frontend web application running on http://localhost:4000/
- and a PostgreSQL database with GTFS data accessible from http://localhost:8001/
At this stage, the frontend should look like this:
It does? Great, that means it's being successfully served by the backend, and that the database does indeed contain GTFS stops.
Note that the starter app actually contains a complete GTFS feed, not just stops. You can delete everything under ./sample-data/
except 3_stops.sql
to lighten the repository by more than a gigabyte.
Now let's draw a map.
1. Leaflet map
Leaflet is "an open-source JavaScript library for mobile-friendly interactive maps". It's super simple to setup and rather nice, so let's go for it.
- Overwrite
./views/index.pug
with some HTML5 boilerplate, a meaningful title, a link to the custom css file, and adiv
to contain the map:
doctype html
html(lang="en")
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title GTFS Stops Map
link(rel="stylesheet", href="/style.css")
body
#map
Note that we're using Pug as a template engine (formerly known as Jade). Refer to the package's documentation for help with the syntax.
-
Download Leaflet's source code, unzip the file, and copy its contents under a new
./public/leaflet/
directory. -
Reference Leaflet's downloaded CSS and JS files in
./views/index.pug
:
// Add this in "head", before style.css:
link(rel="stylesheet", href="/leaflet/leaflet.css")
// Add this in "body", after "#map":
script(src="leaflet/leaflet.js")
Alternatively, you can skip steps 2 and 3 and use Leaflet's CDN links instead, as explained in the Quick Start Guide:
link(
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
)
script(
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
)
- Edit
./public/style.css
to make the map full screen:
html,
body {
height: 100%;
margin: 0;
}
#map {
min-height: 100%;
}
- Edit
./views/index.pug
to add a script that instantiates a map and marker using Leaflet's starter code:
// Add this in "body", after Leaflet's JS:
script.
var map = L.map('map').setView([51.505, -0.09], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution:
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
var marker = L.marker([51.5, -0.09]).addTo(map);
Note that this has to be added in the pug template, not in a separate static file, or it won't be able to use variables passed from the backend.
Refresh http://localhost:4000/ and voilà, a map with a marker! It looks like this:
2. Stop locations display
Thanks to the standard setup, the stop locations are already available in the database and accessible from ./src/index.ts
. We just have to inject them in Leaflet's script.
- Edit
./src/index.ts
to retrieve all stop locations rather than one sample name:
// Replace this:
const stop = await pool.query('SELECT stop_name FROM stops LIMIT 1');
const message = `I'm a GTFS stop. My name is ${stop.rows[0].stop_name}`;
response.render('index', { test: message });
// With this:
const stops = await pool.query('SELECT stop_name, stop_lat, stop_lon FROM stops');
response.render('index', { stops: JSON.stringify(stops.rows) });
- Edit
./views/index.pug
to load the map on Switzerland's geographical centre then create a marker for each stop:
script.
// Change the center and zoom level here:
var map = L.map('map').setView([46.801111, 8.226667], 8);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution:
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// Generate a marker for each GTFS stop, with a popup containing its name:
!{stops}.forEach((stop) => {
L.marker([stop.stop_lat, stop.stop_lon]).addTo(map).bindPopup(stop.stop_name);
});
Save, refresh the page, wait for it. Wait some more. Waaaaiiit. Okay after several minutes, the page may (not) (partially) load. Here's how it looks in my browser to illustrate this:
No map, but we do have a bunch of markers.
Just Leaflet's default grey background.
Nothing, not even an error.
The most interesting: a map slowly taken over by ghost markers.
You can add a limit to the query (i.e. SELECT stop_name, stop_lat ... LIMIT 100
) to have a manageable number of markers that allows the map to load. But there's a better solution.
3. Performant display
To improve performance, let's use a combination of GeoJSON data and Leaflet Circle Markers rendered on a canvas.
GeoJSON is a format for encoding a variety of geographic data structures. – Source
GeoJSON [is] simple, lightweight, straightforward, and Leaflet is quite good at handling it. – Source
The performance issue is due to the fact that each [regular] marker is an individual DOM element. Browsers struggle in rendering thousands of them (...) an easy workaround would be instead to use Circle Markers and have them rendered on a Canvas. – Source
- In
./src/index.ts
, convert the stop rows to GeoJSON objects:
// After querying the stops, convert them:
const geoJson = stops.rows.map((stop) => ({
type: 'Feature',
properties: { name: stop.stop_name },
geometry: { type: 'Point', coordinates: [stop.stop_lon, stop.stop_lat] }
}));
// Then pass the converted data in the renderer:
response.render('index', { stops: JSON.stringify(geoJson) });
- Edit
./views/index.pug
to use the map as a canvas and display the GeoJSON data as circle markers:
script.
// Add the "preferCanvas" option here:
var map = L.map('map', { preferCanvas: true }).setView([46.801111, 8.226667], 8);
// Define a marker style after the tileLayer configuration:
const markerStyle = {
radius: 5,
fillColor: "#0096FF",
color: "#0000FF",
weight: 1,
opacity: 1,
fillOpacity: 0.8
};
// Replace the loop adding regular markers to the map with this one:
L.geoJSON(!{stops}, {
pointToLayer: function (feature, pointCoordinates) {
return L.circleMarker(pointCoordinates, markerStyle).bindPopup(feature.properties.name);
}
}).addTo(map);
Refresh the browser page: this time the map loads with all the stops after a couple of seconds!
It's still a bit sluggish at higher zoom levels, but that's to be expected with over 70,000 markers! At lower zoom levels, it's a perfectly nice user experience.
What's next?
To improve this project, you could:
-
add layers to filter stops by agency, route, etc.