Static Transport Network Diagram — Bokeh

A directed network visualization for transportation systems where stations are displayed as labeled nodes and train/bus routes as directed edges. Edges display departure times, arrival times, and route identifiers. Designed for visualizing timetables, route maps, and connection patterns in rail, bus, or flight networks. This static version focuses on clear, readable presentation without interactive repositioning.

Static Transport Network Diagram rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
network-transport-static: Static Transport Network Diagram
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 95/100 | Updated: 2026-05-18
"""

import os
import sys
import time
from pathlib import Path


# Remove the current directory from sys.path to avoid circular imports with bokeh.py
sys.path = [p for p in sys.path if p not in ("", ".", os.getcwd(), os.path.dirname(__file__))]

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import Arrow, ColumnDataSource, Label, LabelSet, VeeHead
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Okabe-Ito palette - first series is always #009E73
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477"]

np.random.seed(42)

# Data: Regional rail network with stations and routes
# Scaled coordinates for 4800x2700 canvas with margins
stations = [
    {"id": "A", "label": "Central Station", "x": 500, "y": 1350},
    {"id": "B", "label": "North Terminal", "x": 1100, "y": 2100},
    {"id": "C", "label": "East Junction", "x": 1900, "y": 1350},
    {"id": "D", "label": "South Hub", "x": 1100, "y": 600},
    {"id": "E", "label": "Airport", "x": 2700, "y": 1900},
    {"id": "F", "label": "University", "x": 2700, "y": 800},
    {"id": "G", "label": "Industrial Park", "x": 3600, "y": 1350},
    {"id": "H", "label": "Harbor", "x": 700, "y": 2300},
    {"id": "I", "label": "Stadium", "x": 700, "y": 400},
    {"id": "J", "label": "Tech Campus", "x": 3200, "y": 2100},
    {"id": "K", "label": "Medical Center", "x": 3200, "y": 600},
    {"id": "L", "label": "Downtown", "x": 2300, "y": 1350},
]

station_lookup = {s["id"]: s for s in stations}

# Routes: Train services with departure and arrival times
routes = [
    {"source_id": "A", "target_id": "B", "route_id": "RE 10", "departure_time": "06:00", "arrival_time": "06:25"},
    {"source_id": "A", "target_id": "D", "route_id": "RE 20", "departure_time": "06:15", "arrival_time": "06:35"},
    {"source_id": "A", "target_id": "C", "route_id": "RE 30", "departure_time": "06:30", "arrival_time": "07:00"},
    {"source_id": "B", "target_id": "H", "route_id": "S1", "departure_time": "06:35", "arrival_time": "06:50"},
    {"source_id": "B", "target_id": "E", "route_id": "RE 10", "departure_time": "06:40", "arrival_time": "07:15"},
    {"source_id": "C", "target_id": "L", "route_id": "RE 30", "departure_time": "07:05", "arrival_time": "07:20"},
    {"source_id": "C", "target_id": "F", "route_id": "RE 40", "departure_time": "07:10", "arrival_time": "07:35"},
    {"source_id": "D", "target_id": "I", "route_id": "S2", "departure_time": "06:45", "arrival_time": "07:00"},
    {"source_id": "D", "target_id": "F", "route_id": "RE 20", "departure_time": "06:50", "arrival_time": "07:25"},
    {"source_id": "E", "target_id": "J", "route_id": "RE 10", "departure_time": "07:25", "arrival_time": "07:40"},
    {"source_id": "F", "target_id": "G", "route_id": "RE 40", "departure_time": "07:45", "arrival_time": "08:10"},
    {"source_id": "F", "target_id": "K", "route_id": "RE 20", "departure_time": "07:35", "arrival_time": "07:55"},
    {"source_id": "L", "target_id": "E", "route_id": "EX 1", "departure_time": "07:30", "arrival_time": "07:50"},
    {"source_id": "L", "target_id": "G", "route_id": "RE 30", "departure_time": "07:25", "arrival_time": "08:00"},
    {"source_id": "J", "target_id": "G", "route_id": "RE 10", "departure_time": "07:50", "arrival_time": "08:15"},
    {"source_id": "K", "target_id": "G", "route_id": "RE 20", "departure_time": "08:05", "arrival_time": "08:25"},
    {"source_id": "A", "target_id": "L", "route_id": "EX 1", "departure_time": "07:00", "arrival_time": "07:20"},
    {"source_id": "H", "target_id": "A", "route_id": "S1", "departure_time": "05:30", "arrival_time": "05:55"},
    {"source_id": "I", "target_id": "A", "route_id": "S2", "departure_time": "05:45", "arrival_time": "06:10"},
]

# Create figure
p = figure(
    width=4800,
    height=2700,
    title="network-transport-static · python · bokeh · anyplot.ai",
    x_range=(100, 4100),
    y_range=(100, 2550),
    tools="",
    toolbar_location=None,
)

# Style the figure - scaled for 4800x2700 canvas with theme-adaptive colors
p.title.text_font_size = "28pt"
p.title.text_color = INK
p.title.align = "center"
p.xgrid.visible = False
p.ygrid.visible = False
p.xaxis.visible = False
p.yaxis.visible = False
p.outline_line_color = None
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.min_border_left = 80
p.min_border_right = 80
p.min_border_top = 100
p.min_border_bottom = 80

# Draw routes as arrows with labels
for route in routes:
    src = station_lookup[route["source_id"]]
    tgt = station_lookup[route["target_id"]]

    # Calculate direction vector
    dx = tgt["x"] - src["x"]
    dy = tgt["y"] - src["y"]
    length = np.sqrt(dx**2 + dy**2)

    # Normalize direction
    if length > 0:
        nx, ny = dx / length, dy / length
    else:
        nx, ny = 0, 0

    # Offset for multiple routes between same stations (perpendicular offset)
    # Find how many routes share this pair
    pair_key = tuple(sorted([route["source_id"], route["target_id"]]))
    same_pair_routes = [r for r in routes if tuple(sorted([r["source_id"], r["target_id"]])) == pair_key]
    pair_index = same_pair_routes.index(route)
    num_routes = len(same_pair_routes)

    # Perpendicular vector
    perp_x, perp_y = -ny, nx
    offset_amount = (pair_index - (num_routes - 1) / 2) * 80

    # Start and end points with offset and shortened to not overlap nodes
    node_radius = 120
    start_x = src["x"] + nx * node_radius + perp_x * offset_amount
    start_y = src["y"] + ny * node_radius + perp_y * offset_amount
    end_x = tgt["x"] - nx * node_radius + perp_x * offset_amount
    end_y = tgt["y"] - ny * node_radius + perp_y * offset_amount

    route_id = route["route_id"]
    if route_id.startswith("RE"):
        color = IMPRINT[0]
    elif route_id.startswith("S"):
        color = IMPRINT[1]
    elif route_id.startswith("EX"):
        color = IMPRINT[2]
    else:
        color = IMPRINT[3]

    # Draw arrow - scaled for large canvas
    p.add_layout(
        Arrow(
            end=VeeHead(size=35, fill_color=color, line_color=color),
            x_start=start_x,
            y_start=start_y,
            x_end=end_x,
            y_end=end_y,
            line_color=color,
            line_width=6,
            line_alpha=0.85,
        )
    )

    # Route label at midpoint
    mid_x = (start_x + end_x) / 2 + perp_x * 40
    mid_y = (start_y + end_y) / 2 + perp_y * 40

    label_text = f"{route['route_id']} | {route['departure_time']}→{route['arrival_time']}"

    # Calculate angle for label rotation
    angle = np.arctan2(dy, dx)
    if angle > np.pi / 2:
        angle -= np.pi
    elif angle < -np.pi / 2:
        angle += np.pi

    p.add_layout(
        Label(
            x=mid_x,
            y=mid_y,
            text=label_text,
            text_font_size="18pt",
            text_color=color,
            text_font_style="bold",
            text_align="center",
            text_baseline="middle",
            angle=angle,
            background_fill_color=ELEVATED_BG,
            background_fill_alpha=0.9,
        )
    )

# Draw station nodes
station_x = [s["x"] for s in stations]
station_y = [s["y"] for s in stations]
station_labels = [s["label"] for s in stations]

station_source = ColumnDataSource(data={"x": station_x, "y": station_y, "label": station_labels})

# Draw station circles - larger for visibility
p.scatter(x="x", y="y", source=station_source, size=100, fill_color=ELEVATED_BG, line_color=INK_SOFT, line_width=6)

# Station labels - positioned below nodes for clarity
labels = LabelSet(
    x="x",
    y="y",
    text="label",
    source=station_source,
    text_font_size="18pt",
    text_font_style="bold",
    text_color=INK,
    text_align="center",
    text_baseline="top",
    y_offset=-70,
)
p.add_layout(labels)

# Add legend - positioned and scaled for large canvas
legend_x = 3750
legend_y = 2400
legend_items = [
    ("Regional Express (RE)", IMPRINT[0]),
    ("S-Bahn Local (S)", IMPRINT[1]),
    ("Express (EX)", IMPRINT[2]),
]

for i, (label, color) in enumerate(legend_items):
    y_pos = legend_y - i * 150
    p.scatter(x=[legend_x], y=[y_pos], size=40, fill_color=color, line_color=color)
    p.add_layout(
        Label(x=legend_x + 70, y=y_pos, text=label, text_font_size="24pt", text_color=INK, text_baseline="middle")
    )

# Save HTML
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome
W, H = 4800, 2700
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Static Transport Network Diagram on anyplot.ai.

Other implementations