Skip to content

Commit 76e8e9d

Browse files
feat(letsplot): implement network-transport-static (#3600)
## Implementation: `network-transport-static` - letsplot Implements the **letsplot** version of `network-transport-static`. **File:** `plots/network-transport-static/implementations/letsplot.py` **Parent Issue:** #3463 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20868910557)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d86ddd9 commit 76e8e9d

File tree

2 files changed

+420
-0
lines changed

2 files changed

+420
-0
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)