Static Transport Network Diagram — Pygal

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 Pygal

Python source (Pygal)

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

import os
import sys
import xml.etree.ElementTree as ET


# Remove current directory from sys.path before importing to avoid circular import
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _script_dir]

import cairosvg


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

# Okabe-Ito palette (colorblind-safe)
BRAND = "#009E73"  # Position 1 - brand green
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")

# Route type colors (using Okabe-Ito positions)
route_colors = {
    "RE": IMPRINT[0],  # Regional Express - brand green
    "EX": IMPRINT[1],  # Express - vermillion
    "LO": IMPRINT[2],  # Local - blue
}

# Station data: regional rail network
stations = [
    {"id": "A", "label": "Central Station", "x": 0.08, "y": 0.50},
    {"id": "B", "label": "North Park", "x": 0.25, "y": 0.20},
    {"id": "C", "label": "East Harbor", "x": 0.50, "y": 0.30},
    {"id": "D", "label": "South Gate", "x": 0.25, "y": 0.80},
    {"id": "E", "label": "West Valley", "x": 0.50, "y": 0.65},
    {"id": "F", "label": "Airport", "x": 0.75, "y": 0.18},
    {"id": "G", "label": "University", "x": 0.75, "y": 0.50},
    {"id": "H", "label": "Industrial Zone", "x": 0.75, "y": 0.78},
    {"id": "I", "label": "Riverside", "x": 0.50, "y": 0.88},
    {"id": "J", "label": "Tech Campus", "x": 0.92, "y": 0.50},
]

# Routes: train services between stations
routes = [
    {"source": "A", "target": "B", "route_id": "RE 1", "dep": "06:00", "arr": "06:25"},
    {"source": "A", "target": "D", "route_id": "RE 2", "dep": "06:15", "arr": "06:45"},
    {"source": "B", "target": "C", "route_id": "RE 1", "dep": "06:30", "arr": "07:00"},
    {"source": "B", "target": "F", "route_id": "EX 10", "dep": "07:00", "arr": "07:40"},
    {"source": "C", "target": "F", "route_id": "RE 3", "dep": "07:10", "arr": "07:35"},
    {"source": "C", "target": "G", "route_id": "RE 4", "dep": "07:20", "arr": "07:50"},
    {"source": "D", "target": "E", "route_id": "RE 5", "dep": "06:50", "arr": "07:25"},
    {"source": "D", "target": "I", "route_id": "LO 20", "dep": "07:00", "arr": "07:20"},
    {"source": "E", "target": "G", "route_id": "RE 5", "dep": "07:30", "arr": "07:55"},
    {"source": "E", "target": "H", "route_id": "RE 6", "dep": "07:35", "arr": "08:00"},
    {"source": "E", "target": "I", "route_id": "LO 21", "dep": "07:40", "arr": "07:55"},
    {"source": "F", "target": "G", "route_id": "EX 10", "dep": "07:45", "arr": "08:05"},
    {"source": "G", "target": "J", "route_id": "EX 11", "dep": "08:10", "arr": "08:30"},
    {"source": "G", "target": "H", "route_id": "RE 7", "dep": "08:00", "arr": "08:20"},
    {"source": "H", "target": "I", "route_id": "RE 8", "dep": "08:25", "arr": "08:50"},
]

# Create station lookup
station_lookup = {s["id"]: s for s in stations}

# Chart dimensions (16:9 landscape)
WIDTH = 4800
HEIGHT = 2700

# Plotting area margins
margin_left = 150
margin_right = 150
margin_top = 200
margin_bottom = 200

plot_width = WIDTH - margin_left - margin_right
plot_height = HEIGHT - margin_top - margin_bottom


def normalize_to_svg(x_norm, y_norm):
    """Convert normalized (0-1) coordinates to SVG coordinates."""
    svg_x = margin_left + x_norm * plot_width
    svg_y = margin_top + y_norm * plot_height
    return svg_x, svg_y


# Build SVG using ElementTree
svg_ns = "http://www.w3.org/2000/svg"
ET.register_namespace("", svg_ns)

svg_root = ET.Element("svg", xmlns=svg_ns, width=str(WIDTH), height=str(HEIGHT), viewBox=f"0 0 {WIDTH} {HEIGHT}")
svg_root.set("style", f"background-color: {PAGE_BG};")

# Definitions for arrow markers
defs = ET.SubElement(svg_root, "defs")
for prefix, color in route_colors.items():
    marker = ET.SubElement(
        defs,
        "marker",
        id=f"arrow-{prefix}",
        markerWidth="12",
        markerHeight="12",
        refX="10",
        refY="4",
        orient="auto",
        markerUnits="strokeWidth",
    )
    path = ET.SubElement(marker, "path")
    path.set("d", "M0,0 L0,8 L12,4 z")
    path.set("fill", color)

# Add title
title_elem = ET.SubElement(svg_root, "text")
title_elem.set("x", str(WIDTH / 2))
title_elem.set("y", "80")
title_elem.set("text-anchor", "middle")
title_elem.set("fill", INK)
title_elem.set("font-size", "56")
title_elem.set("font-family", "sans-serif")
title_elem.set("font-weight", "bold")
title_elem.text = "network-transport-static · python · pygal · anyplot.ai"

# Subtitle
subtitle_elem = ET.SubElement(svg_root, "text")
subtitle_elem.set("x", str(WIDTH / 2))
subtitle_elem.set("y", "140")
subtitle_elem.set("text-anchor", "middle")
subtitle_elem.set("fill", INK_SOFT)
subtitle_elem.set("font-size", "32")
subtitle_elem.set("font-family", "sans-serif")
subtitle_elem.text = "Regional Rail Network - Morning Schedule"

# Plot background
plot_bg = ET.SubElement(svg_root, "rect")
plot_bg.set("x", str(margin_left))
plot_bg.set("y", str(margin_top))
plot_bg.set("width", str(plot_width))
plot_bg.set("height", str(plot_height))
plot_bg.set("fill", PAGE_BG)
plot_bg.set("stroke", INK_MUTED)
plot_bg.set("stroke-width", "2")
plot_bg.set("stroke-opacity", "0.3")

# Group for edges (routes)
edges_g = ET.SubElement(svg_root, "g")
edges_g.set("class", "routes")

# Track edge counts between station pairs for offset calculation
edge_counts = {}
for route in routes:
    key = tuple(sorted([route["source"], route["target"]]))
    edge_counts[key] = edge_counts.get(key, 0) + 1

edge_indices = {}

# Draw edges (routes)
for route in routes:
    src = station_lookup[route["source"]]
    tgt = station_lookup[route["target"]]

    x1, y1 = normalize_to_svg(src["x"], src["y"])
    x2, y2 = normalize_to_svg(tgt["x"], tgt["y"])

    # Get route color based on prefix
    route_prefix = route["route_id"].split()[0]
    color = route_colors.get(route_prefix, BRAND)

    # Calculate edge offset for multiple edges between same stations
    key = tuple(sorted([route["source"], route["target"]]))
    idx = edge_indices.get(key, 0)
    edge_indices[key] = idx + 1
    total = edge_counts[key]

    # Calculate perpendicular offset
    dx = x2 - x1
    dy = y2 - y1
    length = (dx**2 + dy**2) ** 0.5
    if length > 0 and total > 1:
        # Offset perpendicular to line
        offset_amount = (idx - (total - 1) / 2) * 35
        perp_x = -dy / length * offset_amount
        perp_y = dx / length * offset_amount
        x1 += perp_x
        y1 += perp_y
        x2 += perp_x
        y2 += perp_y

    # Shorten line to not overlap with node circles
    node_radius = 45
    if length > 0:
        # Shorten from start
        x1 += dx / length * node_radius
        y1 += dy / length * node_radius
        # Shorten from end
        x2 -= dx / length * node_radius
        y2 -= dy / length * node_radius

    # Draw edge line with arrow
    line = ET.SubElement(edges_g, "line")
    line.set("x1", f"{x1:.1f}")
    line.set("y1", f"{y1:.1f}")
    line.set("x2", f"{x2:.1f}")
    line.set("y2", f"{y2:.1f}")
    line.set("stroke", color)
    line.set("stroke-width", "5")
    line.set("stroke-opacity", "0.8")
    line.set("marker-end", f"url(#arrow-{route_prefix})")

    # Edge label at midpoint
    mid_x = (x1 + x2) / 2
    mid_y = (y1 + y2) / 2

    # Label background
    label_text = f"{route['route_id']} | {route['dep']} → {route['arr']}"
    label_bg = ET.SubElement(edges_g, "rect")
    label_bg.set("x", f"{mid_x - 130:.1f}")
    label_bg.set("y", f"{mid_y - 18:.1f}")
    label_bg.set("width", "260")
    label_bg.set("height", "36")
    label_bg.set("fill", ELEVATED_BG)
    label_bg.set("fill-opacity", "0.95")
    label_bg.set("rx", "6")
    label_bg.set("stroke", color)
    label_bg.set("stroke-width", "1")
    label_bg.set("stroke-opacity", "0.5")

    # Label text
    label_elem = ET.SubElement(edges_g, "text")
    label_elem.set("x", f"{mid_x:.1f}")
    label_elem.set("y", f"{mid_y + 7:.1f}")
    label_elem.set("text-anchor", "middle")
    label_elem.set("fill", color)
    label_elem.set("font-size", "22")
    label_elem.set("font-family", "sans-serif")
    label_elem.set("font-weight", "600")
    label_elem.text = label_text

    # Tooltip
    title = ET.SubElement(line, "title")
    title.text = f"{route['route_id']}: {route['dep']} → {route['arr']}"

# Group for nodes (stations)
nodes_g = ET.SubElement(svg_root, "g")
nodes_g.set("class", "stations")

# Draw nodes (stations) on top
for station in stations:
    x, y = normalize_to_svg(station["x"], station["y"])

    # Node outer circle (border)
    outer = ET.SubElement(nodes_g, "circle")
    outer.set("cx", f"{x:.1f}")
    outer.set("cy", f"{y:.1f}")
    outer.set("r", "50")
    outer.set("fill", BRAND)  # Okabe-Ito position 1
    outer.set("stroke", INK)
    outer.set("stroke-width", "5")

    # Station ID inside node
    id_elem = ET.SubElement(nodes_g, "text")
    id_elem.set("x", f"{x:.1f}")
    id_elem.set("y", f"{y + 12:.1f}")
    id_elem.set("text-anchor", "middle")
    id_elem.set("fill", PAGE_BG)
    id_elem.set("font-size", "36")
    id_elem.set("font-family", "sans-serif")
    id_elem.set("font-weight", "bold")
    id_elem.text = station["id"]

    # Station name label below node
    label_bg = ET.SubElement(nodes_g, "rect")
    label_bg.set("x", f"{x - 110:.1f}")
    label_bg.set("y", f"{y + 60:.1f}")
    label_bg.set("width", "220")
    label_bg.set("height", "36")
    label_bg.set("fill", ELEVATED_BG)
    label_bg.set("fill-opacity", "0.95")
    label_bg.set("rx", "6")
    label_bg.set("stroke", INK_MUTED)
    label_bg.set("stroke-width", "1")
    label_bg.set("stroke-opacity", "0.3")

    label_elem = ET.SubElement(nodes_g, "text")
    label_elem.set("x", f"{x:.1f}")
    label_elem.set("y", f"{y + 86:.1f}")
    label_elem.set("text-anchor", "middle")
    label_elem.set("fill", INK)
    label_elem.set("font-size", "24")
    label_elem.set("font-family", "sans-serif")
    label_elem.set("font-weight", "600")
    label_elem.text = station["label"]

    # Tooltip
    title = ET.SubElement(outer, "title")
    title.text = f"{station['id']}: {station['label']}"

# Legend
legend_g = ET.SubElement(svg_root, "g")
legend_g.set("class", "legend")
legend_x = WIDTH - 420
legend_y = HEIGHT - 180

# Legend background
legend_bg = ET.SubElement(legend_g, "rect")
legend_bg.set("x", str(legend_x - 30))
legend_bg.set("y", str(legend_y - 50))
legend_bg.set("width", "400")
legend_bg.set("height", "160")
legend_bg.set("fill", ELEVATED_BG)
legend_bg.set("fill-opacity", "0.95")
legend_bg.set("stroke", INK_MUTED)
legend_bg.set("stroke-width", "2")
legend_bg.set("stroke-opacity", "0.3")
legend_bg.set("rx", "10")

# Legend title
legend_title = ET.SubElement(legend_g, "text")
legend_title.set("x", str(legend_x))
legend_title.set("y", str(legend_y - 10))
legend_title.set("fill", INK)
legend_title.set("font-size", "32")
legend_title.set("font-family", "sans-serif")
legend_title.set("font-weight", "bold")
legend_title.text = "Route Types"

# Legend items
legend_items = [("RE - Regional Express", IMPRINT[0]), ("EX - Express", IMPRINT[1]), ("LO - Local", IMPRINT[2])]

for i, (label, color) in enumerate(legend_items):
    ly = legend_y + 30 + i * 35

    # Line symbol
    line_sym = ET.SubElement(legend_g, "line")
    line_sym.set("x1", str(legend_x))
    line_sym.set("y1", str(ly))
    line_sym.set("x2", str(legend_x + 50))
    line_sym.set("y2", str(ly))
    line_sym.set("stroke", color)
    line_sym.set("stroke-width", "5")

    # Arrow marker
    arrow = ET.SubElement(legend_g, "polygon")
    arrow.set("points", f"{legend_x + 52},{ly - 6} {legend_x + 52},{ly + 6} {legend_x + 64},{ly}")
    arrow.set("fill", color)

    # Label text
    label_elem = ET.SubElement(legend_g, "text")
    label_elem.set("x", str(legend_x + 80))
    label_elem.set("y", str(ly + 8))
    label_elem.set("fill", INK_SOFT)
    label_elem.set("font-size", "26")
    label_elem.set("font-family", "sans-serif")
    label_elem.text = label

# Convert SVG to string
svg_output = ET.tostring(svg_root, encoding="unicode")

# Write HTML file
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>network-transport-static - pygal - anyplot.ai</title>
    <style>
        body {{ margin: 0; padding: 20px; background: {PAGE_BG}; font-family: sans-serif; }}
        .container {{ max-width: 100%; overflow: auto; }}
        svg {{ display: block; margin: 0 auto; box-shadow: 0 4px 20px rgba(0,0,0,0.15); }}
    </style>
</head>
<body>
    <div class="container">
        {svg_output}
    </div>
</body>
</html>"""

with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
    f.write(html_content)

# Render to PNG using cairosvg
cairosvg.svg2png(
    bytestring=svg_output.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=WIDTH, output_height=HEIGHT
)

Part of Static Transport Network Diagram on anyplot.ai.

Other implementations