|
| 1 | +""" pyplots.ai |
| 2 | +network-transport-static: Static Transport Network Diagram |
| 3 | +Library: letsplot 4.8.2 | Python 3.13.11 |
| 4 | +Quality: 90/100 | Created: 2026-01-09 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from lets_plot import ( |
| 10 | + LetsPlot, |
| 11 | + aes, |
| 12 | + arrow, |
| 13 | + coord_fixed, |
| 14 | + element_text, |
| 15 | + geom_point, |
| 16 | + geom_segment, |
| 17 | + geom_text, |
| 18 | + ggplot, |
| 19 | + ggsave, |
| 20 | + ggsize, |
| 21 | + labs, |
| 22 | + layer_tooltips, |
| 23 | + scale_color_manual, |
| 24 | + scale_x_continuous, |
| 25 | + scale_y_continuous, |
| 26 | + theme, |
| 27 | + theme_void, |
| 28 | +) |
| 29 | + |
| 30 | + |
| 31 | +LetsPlot.setup_html() |
| 32 | + |
| 33 | +# Data: Regional rail network with stations and routes |
| 34 | +np.random.seed(42) |
| 35 | + |
| 36 | +# Station data with x, y coordinates (positioned like a simplified rail map) |
| 37 | +stations = [ |
| 38 | + {"id": "A", "label": "Central", "x": 0.5, "y": 0.5}, |
| 39 | + {"id": "B", "label": "North", "x": 0.5, "y": 0.88}, |
| 40 | + {"id": "C", "label": "East", "x": 0.82, "y": 0.5}, |
| 41 | + {"id": "D", "label": "South", "x": 0.5, "y": 0.12}, |
| 42 | + {"id": "E", "label": "West", "x": 0.18, "y": 0.5}, |
| 43 | + {"id": "F", "label": "Airport", "x": 0.82, "y": 0.82}, |
| 44 | + {"id": "G", "label": "University", "x": 0.18, "y": 0.82}, |
| 45 | + {"id": "H", "label": "Harbor", "x": 0.82, "y": 0.18}, |
| 46 | + {"id": "I", "label": "Tech Park", "x": 0.18, "y": 0.18}, |
| 47 | + {"id": "J", "label": "Stadium", "x": 0.66, "y": 0.32}, |
| 48 | +] |
| 49 | + |
| 50 | +# Route data: train connections with times |
| 51 | +# Includes multiple routes between same stations to demonstrate curved edges |
| 52 | +routes = [ |
| 53 | + # Express routes (RE) - Central hub connections |
| 54 | + {"source": "A", "target": "B", "route_id": "RE1", "depart": "06:15", "arrive": "06:35", "type": "Express"}, |
| 55 | + {"source": "A", "target": "C", "route_id": "RE2", "depart": "06:30", "arrive": "06:55", "type": "Express"}, |
| 56 | + {"source": "A", "target": "D", "route_id": "RE3", "depart": "07:00", "arrive": "07:25", "type": "Express"}, |
| 57 | + {"source": "A", "target": "E", "route_id": "RE4", "depart": "07:15", "arrive": "07:40", "type": "Express"}, |
| 58 | + # Regional routes (RB) - Connecting outer stations |
| 59 | + {"source": "B", "target": "F", "route_id": "RB1", "depart": "07:00", "arrive": "07:20", "type": "Regional"}, |
| 60 | + {"source": "B", "target": "G", "route_id": "RB2", "depart": "07:30", "arrive": "07:55", "type": "Regional"}, |
| 61 | + {"source": "C", "target": "F", "route_id": "RB3", "depart": "08:00", "arrive": "08:25", "type": "Regional"}, |
| 62 | + {"source": "C", "target": "H", "route_id": "RB4", "depart": "08:15", "arrive": "08:40", "type": "Regional"}, |
| 63 | + {"source": "D", "target": "H", "route_id": "RB5", "depart": "08:30", "arrive": "08:55", "type": "Regional"}, |
| 64 | + {"source": "D", "target": "I", "route_id": "RB6", "depart": "09:00", "arrive": "09:30", "type": "Regional"}, |
| 65 | + {"source": "E", "target": "G", "route_id": "RB7", "depart": "09:15", "arrive": "09:40", "type": "Regional"}, |
| 66 | + {"source": "E", "target": "I", "route_id": "RB8", "depart": "09:30", "arrive": "09:55", "type": "Regional"}, |
| 67 | + # Local routes (S) - Short connections, including multiple routes to same destination |
| 68 | + {"source": "C", "target": "J", "route_id": "S1", "depart": "10:00", "arrive": "10:12", "type": "Local"}, |
| 69 | + {"source": "J", "target": "H", "route_id": "S2", "depart": "10:15", "arrive": "10:30", "type": "Local"}, |
| 70 | + {"source": "A", "target": "J", "route_id": "S3", "depart": "10:30", "arrive": "10:50", "type": "Local"}, |
| 71 | + # Second Express route A→C to demonstrate offset edges |
| 72 | + {"source": "A", "target": "C", "route_id": "RE5", "depart": "12:30", "arrive": "12:55", "type": "Express"}, |
| 73 | +] |
| 74 | + |
| 75 | +# Create DataFrames |
| 76 | +stations_df = pd.DataFrame(stations) |
| 77 | + |
| 78 | +# Track route counts between station pairs for offset calculation |
| 79 | +route_counts = {} |
| 80 | +for r in routes: |
| 81 | + key = (r["source"], r["target"]) |
| 82 | + route_counts[key] = route_counts.get(key, 0) + 1 |
| 83 | + |
| 84 | +route_index = {} |
| 85 | + |
| 86 | +# Build edge DataFrame with source/target coordinates and curve offsets |
| 87 | +station_coords = {s["id"]: (s["x"], s["y"]) for s in stations} |
| 88 | +edges_data = [] |
| 89 | +for r in routes: |
| 90 | + src_x, src_y = station_coords[r["source"]] |
| 91 | + tgt_x, tgt_y = station_coords[r["target"]] |
| 92 | + |
| 93 | + # Track index for this station pair |
| 94 | + key = (r["source"], r["target"]) |
| 95 | + idx = route_index.get(key, 0) |
| 96 | + route_index[key] = idx + 1 |
| 97 | + total = route_counts[key] |
| 98 | + |
| 99 | + # Shorten edges slightly so arrows don't overlap nodes |
| 100 | + dx, dy = tgt_x - src_x, tgt_y - src_y |
| 101 | + length = np.sqrt(dx**2 + dy**2) |
| 102 | + offset = 0.045 / length if length > 0 else 0 |
| 103 | + |
| 104 | + # Calculate perpendicular offset for curved/offset edges |
| 105 | + perp_x = -dy / length if length > 0 else 0 |
| 106 | + perp_y = dx / length if length > 0 else 0 |
| 107 | + |
| 108 | + # Apply perpendicular offset for multiple routes between same stations (increased for visibility) |
| 109 | + if total > 1: |
| 110 | + curve_offset = 0.05 * (idx - (total - 1) / 2) |
| 111 | + else: |
| 112 | + curve_offset = 0 |
| 113 | + |
| 114 | + # Offset labels perpendicular to edge direction (increased for clarity) |
| 115 | + label_offset = 0.055 |
| 116 | + |
| 117 | + edges_data.append( |
| 118 | + { |
| 119 | + "x": src_x + dx * offset + perp_x * curve_offset, |
| 120 | + "y": src_y + dy * offset + perp_y * curve_offset, |
| 121 | + "xend": tgt_x - dx * offset + perp_x * curve_offset, |
| 122 | + "yend": tgt_y - dy * offset + perp_y * curve_offset, |
| 123 | + "route_id": r["route_id"], |
| 124 | + "depart": r["depart"], |
| 125 | + "arrive": r["arrive"], |
| 126 | + "label": f"{r['route_id']} | {r['depart']} → {r['arrive']}", |
| 127 | + "type": r["type"], |
| 128 | + "mid_x": (src_x + tgt_x) / 2 + perp_x * (label_offset + curve_offset), |
| 129 | + "mid_y": (src_y + tgt_y) / 2 + perp_y * (label_offset + curve_offset), |
| 130 | + "source_station": next(s["label"] for s in stations if s["id"] == r["source"]), |
| 131 | + "target_station": next(s["label"] for s in stations if s["id"] == r["target"]), |
| 132 | + } |
| 133 | + ) |
| 134 | + |
| 135 | +edges_df = pd.DataFrame(edges_data) |
| 136 | + |
| 137 | +# Color palette for route types |
| 138 | +route_colors = {"Express": "#306998", "Regional": "#B8860B", "Local": "#2E8B57"} |
| 139 | + |
| 140 | +# Create tooltip specs for interactive hover |
| 141 | +edge_tooltips = ( |
| 142 | + layer_tooltips() |
| 143 | + .title("@route_id") |
| 144 | + .line("@source_station → @target_station") |
| 145 | + .line("Departs: @depart") |
| 146 | + .line("Arrives: @arrive") |
| 147 | + .line("Type: @type") |
| 148 | +) |
| 149 | + |
| 150 | +station_tooltips = layer_tooltips().title("@label").line("Station ID: @id") |
| 151 | + |
| 152 | +# Create the plot with interactive tooltips |
| 153 | +plot = ( |
| 154 | + ggplot() |
| 155 | + # Draw edges as segments with arrows and tooltips |
| 156 | + + geom_segment( |
| 157 | + aes(x="x", y="y", xend="xend", yend="yend", color="type"), |
| 158 | + data=edges_df, |
| 159 | + size=1.8, |
| 160 | + alpha=0.85, |
| 161 | + arrow=arrow(angle=25, length=12, type="closed"), |
| 162 | + tooltips=edge_tooltips, |
| 163 | + ) |
| 164 | + # Draw edge labels (route and times) - larger for readability |
| 165 | + + geom_text(aes(x="mid_x", y="mid_y", label="label", color="type"), data=edges_df, size=6) |
| 166 | + # Draw station nodes with tooltips |
| 167 | + + geom_point( |
| 168 | + aes(x="x", y="y"), |
| 169 | + data=stations_df, |
| 170 | + size=12, |
| 171 | + color="white", |
| 172 | + shape=21, |
| 173 | + fill="#303030", |
| 174 | + stroke=2.5, |
| 175 | + tooltips=station_tooltips, |
| 176 | + ) |
| 177 | + # Draw station labels (adjusted position to avoid edge label overlap) |
| 178 | + + geom_text( |
| 179 | + aes(x="x", y="y", label="label"), data=stations_df, size=9, color="#202020", fontface="bold", nudge_y=-0.055 |
| 180 | + ) |
| 181 | + # Color scale for route types |
| 182 | + + scale_color_manual(values=route_colors, name="Route Type") |
| 183 | + # Styling |
| 184 | + + labs(title="network-transport-static · letsplot · pyplots.ai", x="", y="") |
| 185 | + + theme_void() |
| 186 | + + theme( |
| 187 | + plot_title=element_text(size=24, face="bold", hjust=0.5), |
| 188 | + legend_position="right", |
| 189 | + legend_title=element_text(size=16), |
| 190 | + legend_text=element_text(size=14), |
| 191 | + ) |
| 192 | + + scale_x_continuous(limits=[0, 1]) |
| 193 | + + scale_y_continuous(limits=[0, 1]) |
| 194 | + + coord_fixed(ratio=1) |
| 195 | + + ggsize(1600, 900) |
| 196 | +) |
| 197 | + |
| 198 | +# Save as PNG (scale 3x for 4800x2700) |
| 199 | +ggsave(plot, "plot.png", scale=3, path=".") |
| 200 | + |
| 201 | +# Save as HTML for interactivity (tooltips work in HTML) |
| 202 | +ggsave(plot, "plot.html", path=".") |
0 commit comments