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
from great_tables import GT, html, style, loc, google_fontimport polars as plimport polars.selectors as csstops = pl.read_csv("chw-stops.csv")times = pl.read_csv("times.csv")stop_times = times.join(other=stops, on="stop_name", maintain_order="left").select( pl.lit("To Center City").alias("direction"), pl.col("*"))def h_m_p(s): h, m, _ = [int(part) for part in s.split(":")] ap ="a"if h >12: h -=12 ap ="p"returnf"{h}:{m:02d}{ap}"def tick(b):return"✓"if b else""transit_table = ( GT(stop_times) .tab_stub(groupname_col="direction") .tab_header("Saturdays, Sundays, and Major Holidays") .cols_hide(columns=["stop_url", "zone_id", "stop_desc", "stop_lat", "stop_lon", "stop_id"]) .fmt(h_m_p, columns=cs.matches(r"^[0-9]{4}$")) .fmt(tick, columns=cs.starts_with("service_")) .cols_label( stop_name="Stations", service_access="A", service_cash="C", service_park="P", fare_zone=html("Fare<br>Zone"), ) .tab_spanner(label="Services", columns=cs.starts_with("service_")) .tab_spanner(label="Train Number", columns=cs.matches(r"^[0-9]{4}$")) .cols_move_to_start("fare_zone") .cols_move_to_start(cs.starts_with("service_")) .cols_width(cases={c: "20px"for c in stop_times.columns if c.startswith("service_")}) .cols_width(cases={c: "60px"for c in stop_times.columns if c.startswith("8")}) .opt_row_striping(row_striping=True) .cols_align(align="center", columns="fare_zone") .cols_align(align="right", columns=cs.matches(r"^[0-9]{4}$"))# style header .tab_style( locations=loc.header(), style=style.css("background-color: rgb(66, 99, 128) !important; color: white !important; font-size: 24px !important; font-weight: bold !important; border-width: 0px !important;", ), )# style vertical text on left .tab_style( locations=loc.row_groups(),# TODO: rotate text vertically style=style.css("writing-mode: sideways-lr; padding-bottom: 25% !important; font-size: 24px !important; font-weight: bold !important; text-transform: uppercase !important;" ), ) .tab_style( style=style.css("background-color: black !important; color: white !important; border-top: none !important; border-bottom: none !important;" ), locations=loc.body(columns=None, rows=list(range(-4, -1))), ) .tab_style( style=style.css(""" border-top: none !important; border-bottom: none !important; border-right: solid white 2px !important; color: white !important; """ ), locations=loc.body(columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(-4, -1))), ) .tab_style( style=style.css("border-right: solid black 2px !important;"), locations=loc.body(columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(0, 10)) + [13]), ) .tab_options( row_striping_background_color="#A9A9A9", row_group_as_column=True, ) .opt_table_outline() .opt_table_font(font=google_font("IBM Plex Sans")))transit_table
/opt/hostedtoolcache/Python/3.10.20/x64/lib/python3.10/site-packages/great_tables/_render_checks.py:37: RenderWarning: Rendering table with .cols_width() in Quarto may result in unexpected behavior. This is because Quarto performs custom table processing. Either use all percentage widths, or set .tab_options(quarto_disable_processing=True) to disable Quarto table processing.
warnings.warn(
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.
import polars as plstops = pl.read_csv("chw-stops.csv")times = pl.read_csv("times.csv")
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.
times.head(3)
shape: (3, 9)
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.
stop_times = times.join(other=stops, on="stop_name", maintain_order="left").select( pl.lit("To Center City").alias("direction"), pl.col("*"))stop_times.head(3)
shape: (3, 20)
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!:
from great_tables import GT, html, style, loc, google_fontimport polars as plimport polars.selectors as csdef h_m_p(s): h, m, _ = [int(part) for part in s.split(":")] ap ="a"if h >12: h -=12 ap ="p"returnf"{h}:{m:02d}{ap}"def tick(b):return"✓"if b else""transit_table = ( GT(stop_times)# Create left-hand stub, top header, and hide extra cols -------- .tab_stub(groupname_col="direction") .tab_header("Saturdays, Sundays, and Major Holidays") .cols_hide( columns=["stop_url", "zone_id", "stop_desc", "stop_lat", "stop_lon", "stop_id"] )# custom functions for checkmarks and time formatting ----------- .fmt(h_m_p, columns=cs.matches(r"^[0-9]{4}$")) .fmt(tick, columns=cs.starts_with("service_"))# relabel columns and add spanners (labels over columns) -------- .cols_label( stop_name="Stations", service_access="A", service_cash="C", service_park="P", fare_zone=html("Fare<br>Zone"), ) .tab_spanner(label="Services", columns=cs.starts_with("service_")) .tab_spanner(label="Train Number", columns=cs.matches(r"^[0-9]{4}$"))# move columns around and setting their width and alignment ----- .cols_move_to_start("fare_zone") .cols_move_to_start(cs.starts_with("service_")) .cols_width( cases={c: "18px"for c in stop_times.columns if c.startswith("service_")} ) .cols_width(cases={c: "60px"for c in stop_times.columns if c.startswith("8")}) .cols_align(align="center", columns="fare_zone") .cols_align(align="right", columns=cs.matches(r"^[0-9]{4}$"))# styles: striping, vertical text, background colors, fonts -----# style header .tab_style( locations=loc.header(), style=style.css("background-color: rgb(66, 99, 128) !important; color: white !important; font-size: 24px !important; font-weight: bold !important; border-width: 0px !important;", ), )# style vertical text on left .tab_style( locations=loc.row_groups(), style=style.css("writing-mode: sideways-lr; padding-bottom: 25% !important; font-size: 24px !important; font-weight: bold !important; text-transform: uppercase !important;" ), ) .tab_style( style=style.css("background-color: black !important; color: white !important; border-top: none !important; border-bottom: none !important;" ), locations=loc.body(columns=None, rows=list(range(-4, -1))), ) .tab_style( style=style.css(""" border-top: none !important; border-bottom: none !important; border-right: solid white 2px !important; color: white !important; """ ), locations=loc.body( columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(-4, -1)) ), ) .tab_style( style=style.css("border-right: solid black 2px !important;"), locations=loc.body( columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(0, 10)) + [13] ), ) .tab_options( row_striping_background_color="#A9A9A9", row_group_as_column=True, ) .opt_row_striping(row_striping=True) .opt_table_outline() .opt_table_font(font=google_font("IBM Plex Sans")))transit_table
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).
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!