Tokyo metro map

This example is obviously inspired by the example of London Tube Lines in the Altair and Vega-Lite documentations.

It can feel a bit frustrating when discovering those libraries not to be able to extend it easily to other cities. We pick Tokyo here, and spice it up with a bilingual map (mostly in Japanese, but English appears with the mouse.)

Warning

  • It may happen that the visualisation is rendered before the fonts are downloaded. In case such thing happens, a simple refresh (F5) should be enough to fix things.

  • A close up map is built in the corresponding section.

Data acquisition

We will take two different datasets from OpenStreetMap: background information (the 23 Tokyo wards) and the subway lines. For people familiar with Tokyo subway systems, this does not include the JR lines (incl. Yamanote line).

from cartes.osm import Overpass

tokyo_wards = Overpass.request(
    area={"name:en": "Tokyo", "admin_level": 4},
    # Level 7 include cities (市) and wards (区)
    rel=dict(admin_level=7, name=dict(regex="区$")),
)

tokyo_subway = Overpass.request(
    area={"name:en": "Tokyo", "admin_level": 4, "as_": "tokyo"},
    nwr=[
        dict(railway="subway", area="tokyo"),  # subway lines
        dict(station="subway", area="tokyo"),  # subway stations
    ],
)

Data preprocessing

The map background needs to be simplified, we do not need details at very fine resolution, which would also be heavy to download.

tokyo_wards = tokyo_wards.simplify(1e3)

There are some glitches in the metadata associated to the line segments, so there are two solutions:

  • edit the faulty segments on OpenStreetMap;

  • preprocess the data to correct those mistakes.

lines = tokyo_subway.data.query("type_ == 'way' and name == name").assign(
    name=lambda df: df.name.str.split(" ", n=1, expand=True)
)
lines.loc[lines["name:en"] == "Toei Mita Line", "name"] = "都営地下鉄三田線"
lines.loc[lines["name:en"] == "Toei Asakusa Line", "name"] = "都営地下鉄浅草線"
lines.loc[lines["name"] == "京王電鉄京王線", "name:en"] = "Keio Railway Keio Line"

We collect the official colours associated to each lines from segments where the tag is filled:

colours = (
    lines[["name", "name:en", "colour"]]
    .query("colour==colour")
    .groupby("name")
    .agg({"name:en": "max", "colour": "max"})
    .reset_index()
)
name name:en colour
0 東京メトロ丸ノ内線 Tokyo Metro Marunouchi Line #F62E36
1 東京メトロ副都心線 Tokyo Metro Fukutoshin Line #B74D17
2 東京メトロ千代田線 Tokyo Metro Chiyoda Line #00BB85
3 東京メトロ半蔵門線 Tokyo Metro Hanzomon Line #8F76D6
4 東京メトロ南北線 Tokyo Metro Namboku Line #00AC9B
5 東京メトロ日比谷線 Tokyo Metro Hibiya Line #B5B5AC
6 東京メトロ有楽町線 Tokyo Metro Yurakucho Line #C1A470
7 東京メトロ東西線 Tokyo Metro Tōzai Line #0CA7ED
8 東京メトロ銀座線 Tokyo Metro Ginza Line #FF9500
9 都営地下鉄三田線 Toei Mita Line #0079C2
10 都営地下鉄大江戸線 Toei Oedo Line #B6007A
11 都営地下鉄新宿線 Toei Shinjuku Line #6CBB5A
12 都営地下鉄浅草線 Toei Asakusa Line #E85298

Then we merge the lines into single elements, also in order to reduce the size of resulting JSON. Line simplification does not really work well here if we want subway lines to still go through the stations.

from shapely.ops import linemerge

def merge_line(elt):
    return pd.Series(
        {
            "geometry": linemerge(elt.geometry.tolist()),
            "name:en": elt["name:en"].max(),
        }
    )

lines = (
    lines[["name", "name:en", "geometry"]]
    .groupby("name").apply(merge_line).reset_index()
)

Data visualisation

import altair as alt

# First the colors
line_scale = alt.Scale(
    domain=colours["name:en"].tolist(),
    range=colours["colour"].tolist()
)

wards = alt.Chart(tokyo_wards)

basemap = alt.layer(
    # The background
    wards.mark_geoshape(color="gainsboro", stroke="white", strokeWidth=1.5),
    # The names of the wards: in Japanese, the in English under the mouse pointer
    wards.mark_text(fontSize=16, font="Noto Sans JP", fontWeight=100).encode(
        alt.Text("name:N"), alt.Tooltip("name:ja-Latn:N"),
        alt.Latitude("latitude:Q"), alt.Longitude("longitude:Q"),
    ),
    # The subway lines: in English in the legend, bilingual under the mouse pointer
    alt.Chart(lines).mark_geoshape(filled=False, strokeWidth=2)
    .encode(
        alt.Color(
            "name:en:N", scale=line_scale,
            legend=alt.Legend(
                title=None, orient="bottom-left", offset=0, columns=2,
                labelFont="Ubuntu", labelFontSize=12,
            ),
        ),
        alt.Tooltip(["name:N", "name:en:N"]),
    ),
    # Subway stations positions
    alt.Chart(tokyo_subway.query("station == station"))
    .mark_circle(size=30, color="darkslategray")
    .encode(
        alt.Latitude("latitude:Q"), alt.Longitude("longitude:Q"),
        alt.Tooltip(["name:N", "name:en:N"]),
    ),
).properties(width=600, height=600)

basemap

Zoom in to downtown Tokyo

On this map, we choose to:

  • specify a projection centered on the central wards in order to be able to zoom in;

  • display the station names in small characters;

  • add the Yamanote line (in pale green).

# Collect the Yamonote line
tokyo_yamanote = Overpass.request(
    area={"name:en": "Tokyo", "admin_level": 4},
    way=dict(railway=True, name=dict(regex="山手線$")),
)

# The geometry will be enough here
yamanote = linemerge(tokyo_yamanote.data.geometry.to_list())

alt.layer(
    # Recall previous visualisation
    default,
    # Some stations appear several times (once per exit?)
    alt.Chart(tokyo_stations.data.drop_duplicates("name"))
    .mark_text(fontSize=12, font="Noto Sans JP", fontWeight=100)
    .encode(
        alt.Text("name:N"), alt.Tooltip(["name:N", "name:en:N"]),
        alt.Latitude("latitude:Q"), alt.Longitude("longitude:Q"),
    ),
    # Yamanote line
    alt.Chart(yamanote).mark_geoshape(
        strokeWidth=10, opacity=0.3, color="#B1CB39", filled=False
    )
).project(
    # based on the coordinates of the map center
    "conicConformal", rotate=[-139.77, -35.68], scale=450000
).configure_legend(
    # there is not much space for the legend, so hide what's behind
    fillColor="gainsboro", padding=10
)