Recently, Rich and I were poking around transit data, and we were struck by the amount of structuring that goes into transit timetables.
For example, consider this weekend rail schedule table from SEPTA, Philadelphia’s transit agency.
Notice these big pieces:
- The vertical text on the left indicating trains are traveling “TO CENTER CITY”.
- The blue header, and spanner columns (“Services” and “Train Number”) grouping related columns.
- The striped background for easier reading. Also the black background indicating stations in Center City (the urban core).
Tables like this often have to be created in tools like Illustrator, and updated by hand. At the same time, when agencies automate table creation, they often sacrifice a lot of the assistive features and helpful affordances of the table.
We set out to recreate this table in Great Tables (and by we I mean 99% Rich). In this post, I’ll walk quickly through how we recreated it, and share some other examples of transit timetables in the wild. For the theory behind why tables like this are useful, see The Design Philosophy of Great Tables .
The final result#
Here’s a look at our quick version in Great Tables. In this post we’ll walk through quickly how we created it, but wanted to treat you to the final result up front! Note that the table is fully in HTML for accessibility.
Code
|
|
| Saturdays, Sundays, and Major Holidays | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Services | Fare Zone |
Stations | Train Number | ||||||||||
| A | C | P | 8210 | 8716 | 8318 | 8322 | 8338 | 8242 | 8750 | 8756 | |||
| To Center City | ✓ | 2 | Chestnut Hill West | 6:51a | 8:08a | 8:49a | 9:49a | 1:52p | 2:49p | 4:48p | 6:20p | ||
| ✓ | 2 | Highland | 6:52a | 8:09a | 8:50a | 9:50a | 1:53p | 2:50p | 4:49p | 6:21p | |||
| ✓ | 1 | St. Martins | 6:54a | 8:11a | 8:52a | 9:52a | 1:55p | 2:52p | 4:51p | 6:23p | |||
| ✓ | 1 | Richard Allen Lane | 6:56a | 8:13a | 8:54a | 9:54a | 1:57p | 2:54p | 4:53p | 6:25p | |||
| ✓ | ✓ | 1 | Carpenter | 6:58a | 8:15a | 8:56a | 9:56a | 1:59p | 2:56p | 4:55p | 6:27p | ||
| 1 | Upsal | 7:00a | 8:17a | 8:58a | 9:58a | 2:01p | 2:58p | 4:57p | 6:29p | ||||
| ✓ | ✓ | C | Tulpehocken | 7:02a | 8:19a | 9:00a | 10:00a | 2:03p | 3:00p | 4:59p | 6:31p | ||
| ✓ | ✓ | C | Chelten Avenue | 7:04a | 8:21a | 9:02a | 10:02a | 2:05p | 3:02p | 5:01p | 6:33p | ||
| ✓ | ✓ | C | Queen Lane | 7:06a | 8:23a | 9:04a | 10:04a | 2:07p | 3:04p | 5:03p | 6:35p | ||
| ✓ | C | North Philadelphia | 7:12a | 8:29a | 9:12a | 10:12a | 2:15p | 3:12p | 5:09p | 6:41p | |||
| ✓ | ✓ | 2 | Gray 30th Street | 7:23a | 8:42a | 9:23a | 10:23a | 2:26p | 3:23p | 5:20p | 6:54p | ||
| ✓ | 2 | Suburban Station | 7:28a | 8:47a | 9:28a | 10:28a | 2:31p | 3:28p | 5:25p | 6:59p | |||
| ✓ | 2 | Jefferson Station | 7:33a | 8:52a | 9:33a | 10:33a | 2:36p | 3:33p | 5:30p | 7:04p | |||
| ✓ | ✓ | 2 | Temple University | 7:37a | 8:57a | 9:37a | 10:37a | 2:40p | 3:37p | 5:35p | 7:08p | ||
Reading in stops and times#
For this example, I simplified SEPTA’s transit data down to two pieces:
chw-stops.csv- detailed information about each stop location.times.csv- when a train arrives at a stop on the Chesnut Hill West line. Each row is a stop location, and each column is a trip (e.g. the 6:51am train).
To make the final table we joined these two together, to get the trips and stop information together.
|
|
Here’s a quick preview of stops.
|
|
| stop_name | service_access | service_cash |
|---|---|---|
| str | i64 | i64 |
| "Gray 30th Street" | 1 | 0 |
| "Suburban Station" | 0 | 0 |
| "Jefferson Station" | 0 | 0 |
| "Temple University" | 1 | 0 |
| "Chestnut Hill West" | 0 | 0 |
Notice that the table above has the name of each stop, and a 1 or 0 in the service_access column to indicate whether the stop is wheelchair accessible. Note that a big challenge for this specific route is that sometimes boarding the train requires using steps, and sometimes the station requires using steps. For example, Chelton Ave (not shown) does not require steps to board the train, but the station itself is not wheelchair accessible because of steps to get to the platform.
Here’s a quick preview of the times.
|
|
| stop_name | 8210 | 8716 | 8318 | 8322 | 8338 | 8242 | 8750 | 8756 |
|---|---|---|---|---|---|---|---|---|
| str | str | str | str | str | str | str | str | str |
| "Chestnut Hill West" | "06:51:00" | "08:08:00" | "08:49:00" | "09:49:00" | "13:52:00" | "14:49:00" | "16:48:00" | "18:20:00" |
| "Highland" | "06:52:00" | "08:09:00" | "08:50:00" | "09:50:00" | "13:53:00" | "14:50:00" | "16:49:00" | "18:21:00" |
| "St. Martins" | "06:54:00" | "08:11:00" | "08:52:00" | "09:52:00" | "13:55:00" | "14:52:00" | "16:51:00" | "18:23:00" |
Notice that each trip is a column (i.e. a train leaving from Chesnut Hill West at a specific time), and each row is a stop. For example, the 8210 train is the 6:51am train. (Note that schedules and train numbers can change, so this data may be out of date).
Joining these together gives us stop_times, with trips and stop information on the columns.
|
|
| direction | stop_name | 8210 | 8716 | 8318 | 8322 | 8338 | 8242 | 8750 | 8756 | service_access | service_cash | service_park | fare_zone | stop_id | stop_desc | stop_lat | stop_lon | zone_id | stop_url |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| str | str | str | str | str | str | str | str | str | str | i64 | i64 | i64 | str | i64 | str | f64 | f64 | str | str |
| "To Center City" | "Chestnut Hill West" | "06:51:00" | "08:08:00" | "08:49:00" | "09:49:00" | "13:52:00" | "14:49:00" | "16:48:00" | "18:20:00" | 0 | 0 | 1 | "2" | 90801 | null | 40.076389 | -75.208333 | "2S" | null |
| "To Center City" | "Highland" | "06:52:00" | "08:09:00" | "08:50:00" | "09:50:00" | "13:53:00" | "14:50:00" | "16:49:00" | "18:21:00" | 0 | 0 | 1 | "2" | 90802 | null | 40.070556 | -75.211111 | "2S" | null |
| "To Center City" | "St. Martins" | "06:54:00" | "08:11:00" | "08:52:00" | "09:52:00" | "13:55:00" | "14:52:00" | "16:51:00" | "18:23:00" | 0 | 0 | 1 | "1" | 90803 | null | 40.065833 | -75.204444 | "2S" | null |
Notice that in the table above, the first row tells us when each train leaves Chesnut Hill West, and information about the Chesnut Hill West stop.
Creating the table#
Below is the code for the table, with 5 key activities marked with comments. For example, the first is creating high level structure, like the header and the left-hand “To Center City” stub. Others include formatting in checkmarks, customizing columns (e.g. their width), and styling (e.g. setting background colors and fonts).
It’s a lot to take in, but worth it!:
|
|
| Saturdays, Sundays, and Major Holidays | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Services | Fare Zone |
Stations | Train Number | ||||||||||
| A | C | P | 8210 | 8716 | 8318 | 8322 | 8338 | 8242 | 8750 | 8756 | |||
| To Center City | ✓ | 2 | Chestnut Hill West | 6:51a | 8:08a | 8:49a | 9:49a | 1:52p | 2:49p | 4:48p | 6:20p | ||
| ✓ | 2 | Highland | 6:52a | 8:09a | 8:50a | 9:50a | 1:53p | 2:50p | 4:49p | 6:21p | |||
| ✓ | 1 | St. Martins | 6:54a | 8:11a | 8:52a | 9:52a | 1:55p | 2:52p | 4:51p | 6:23p | |||
| ✓ | 1 | Richard Allen Lane | 6:56a | 8:13a | 8:54a | 9:54a | 1:57p | 2:54p | 4:53p | 6:25p | |||
| ✓ | ✓ | 1 | Carpenter | 6:58a | 8:15a | 8:56a | 9:56a | 1:59p | 2:56p | 4:55p | 6:27p | ||
| 1 | Upsal | 7:00a | 8:17a | 8:58a | 9:58a | 2:01p | 2:58p | 4:57p | 6:29p | ||||
| ✓ | ✓ | C | Tulpehocken | 7:02a | 8:19a | 9:00a | 10:00a | 2:03p | 3:00p | 4:59p | 6:31p | ||
| ✓ | ✓ | C | Chelten Avenue | 7:04a | 8:21a | 9:02a | 10:02a | 2:05p | 3:02p | 5:01p | 6:33p | ||
| ✓ | ✓ | C | Queen Lane | 7:06a | 8:23a | 9:04a | 10:04a | 2:07p | 3:04p | 5:03p | 6:35p | ||
| ✓ | C | North Philadelphia | 7:12a | 8:29a | 9:12a | 10:12a | 2:15p | 3:12p | 5:09p | 6:41p | |||
| ✓ | ✓ | 2 | Gray 30th Street | 7:23a | 8:42a | 9:23a | 10:23a | 2:26p | 3:23p | 5:20p | 6:54p | ||
| ✓ | 2 | Suburban Station | 7:28a | 8:47a | 9:28a | 10:28a | 2:31p | 3:28p | 5:25p | 6:59p | |||
| ✓ | 2 | Jefferson Station | 7:33a | 8:52a | 9:33a | 10:33a | 2:36p | 3:33p | 5:30p | 7:04p | |||
| ✓ | ✓ | 2 | Temple University | 7:37a | 8:57a | 9:37a | 10:37a | 2:40p | 3:37p | 5:35p | 7:08p | ||
Other schedules in the wild#
MetroTransit in Minneapolis uses a transposed format, with stops as columns and trips as rows. Here’s an example from their Route 2 bus timetable :
This is useful when there a lot of trips, because with trips on the rows readers can scroll down (versus needing to scroll sideways).
The MTA in New York City is similar. Here’s an example of their bx1 bus route timetable :
What I like about all these tables is they highlight the structure behind bus and train routes. Sometimes they skip certain stops. But realistically, what makes them a route is that trips tend to make the same stops over and over.
A common alternative to using these tables is to do routing from a set start to end point. For example, below is a form for selecting a start and end point on SEPTA’s website, with a resulting table of departure and arrival times.
Notice that the table has removed a lot of information about intermediate stops people might not care about.
In conclusion#
Transit tables are richly structured displays of information. They take advantage often of the fact that a train route like Chesnut Hill West is a fixed set of stops–so that stops can be on the rows, and arrival times for trips throughout the day can be on the columns.
This is intuitive to people reading transit timetables, but can get tricky to display on the web. Timetables are a core part of navigating transit networks, so it was a fun experiment to try replicating one of Septa’s timetables in Great Tables!


