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:

image

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, and docker volume rm $(docker volume ls -q), then try docker-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:

image

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.

  1. Overwrite ./views/index.pug with some HTML5 boilerplate, a meaningful title, a link to the custom css file, and a div 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.

  1. Download Leaflet's source code, unzip the file, and copy its contents under a new ./public/leaflet/ directory.

  2. 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=""
)
  1. Edit ./public/style.css to make the map full screen:
html,
body {
  height: 100%;
  margin: 0;
}

#map {
  min-height: 100%;
}
  1. 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:
      '&copy; <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:

image

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.

  1. 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) });
  1. 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:
      '&copy; <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

  1. 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) });
  1. 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: