Skip to content

Commit a7369f6

Browse files
feat(letsplot): implement area-elevation-profile (#4894)
## Implementation: `area-elevation-profile` - letsplot Implements the **letsplot** version of `area-elevation-profile`. **File:** `plots/area-elevation-profile/implementations/letsplot.py` **Parent Issue:** #4578 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23120069378)* --------- 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 c3a54b1 commit a7369f6

2 files changed

Lines changed: 413 additions & 0 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
""" pyplots.ai
2+
area-elevation-profile: Terrain Elevation Profile Along Transect
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 85/100 | Created: 2026-03-15
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import * # noqa: F403, F401
10+
from lets_plot.export import ggsave as export_ggsave
11+
12+
13+
LetsPlot.setup_html() # noqa: F405
14+
15+
# Data — Alpine hiking trail elevation profile (120 km)
16+
np.random.seed(42)
17+
n_points = 480
18+
distance = np.linspace(0, 120, n_points)
19+
20+
# Build realistic terrain with multiple overlapping features
21+
base_elevation = 1200
22+
broad_shape = 600 * np.sin(distance * np.pi / 120)
23+
ridge1 = 400 * np.exp(-((distance - 35) ** 2) / 80)
24+
ridge2 = 550 * np.exp(-((distance - 65) ** 2) / 120)
25+
ridge3 = 350 * np.exp(-((distance - 95) ** 2) / 60)
26+
valley = -300 * np.exp(-((distance - 50) ** 2) / 50)
27+
noise = np.cumsum(np.random.randn(n_points) * 3)
28+
elevation = base_elevation + broad_shape + ridge1 + ridge2 + ridge3 + valley + noise
29+
elevation = np.clip(elevation, 800, None)
30+
31+
df = pd.DataFrame({"distance": distance, "elevation": elevation})
32+
33+
# Landmarks along the trail
34+
landmark_names = [
35+
"Talbach Village",
36+
"Steinberg Pass",
37+
"Grünsee Lake",
38+
"Hochwand Summit",
39+
"Felsentor Saddle",
40+
"Alpenhof Hut",
41+
"Gipfelkreuz Peak",
42+
"Bergdorf Village",
43+
]
44+
landmark_distances = [0, 20, 38, 50, 65, 80, 95, 120]
45+
landmark_elevations = [float(np.interp(d, distance, elevation)) for d in landmark_distances]
46+
47+
# Stagger label offsets — alternate high/low in crowded 50-100 km region
48+
# Alternating low/high nudge with horizontal shifts to prevent overlap
49+
nudge_y = [80, 250, 80, 250, 80, 250, 80, 80]
50+
nudge_x = [0, -2, 2, -3, 0, 0, 0, 0]
51+
landmarks_df = pd.DataFrame(
52+
{
53+
"distance": landmark_distances,
54+
"elevation": landmark_elevations,
55+
"name": landmark_names,
56+
"label_y": [e + n for e, n in zip(landmark_elevations, nudge_y, strict=True)],
57+
"label_x": [d + n for d, n in zip(landmark_distances, nudge_x, strict=True)],
58+
}
59+
)
60+
61+
# Compute slope for gradient coloring — smoothed to reduce visual fragmentation
62+
slope = np.gradient(elevation, distance)
63+
slope_abs = np.abs(slope)
64+
# Rolling average to prevent rapid color switching on steep transitions
65+
slope_smooth = pd.Series(slope_abs).rolling(window=25, center=True, min_periods=1).mean()
66+
slope_category = pd.cut(slope_smooth, bins=[0, 15, 40, np.inf], labels=["Flat/Gentle", "Moderate", "Steep"])
67+
df["slope_category"] = slope_category
68+
69+
# Segment data for vertical landmark lines (using geom_segment)
70+
segments_df = pd.DataFrame(
71+
{"x": landmark_distances, "y": landmark_elevations, "yend": [float(min(elevation)) - 20] * len(landmark_distances)}
72+
)
73+
74+
# Colorblind-safe slope palette: blue, amber, deep purple
75+
slope_colors = ["#306998", "#E69F00", "#882255"]
76+
77+
# Y-axis range — tighten floor to reduce dead space
78+
y_floor = int(min(elevation)) - 30
79+
y_max = int(max(elevation) * 1.15)
80+
81+
# Plot
82+
plot = (
83+
ggplot(df, aes(x="distance", y="elevation")) # noqa: F405
84+
# Filled area for terrain silhouette with gradient-like effect
85+
+ geom_area(fill="#306998", alpha=0.15) # noqa: F405
86+
+ geom_area(fill="#306998", alpha=0.10) # noqa: F405
87+
# Profile line colored by slope steepness
88+
+ geom_line( # noqa: F405
89+
aes(color="slope_category"), # noqa: F405
90+
size=2.5,
91+
tooltips=layer_tooltips() # noqa: F405
92+
.line("@|@elevation")
93+
.line("Distance: @distance km")
94+
.line("Slope: @slope_category"),
95+
)
96+
+ scale_color_manual( # noqa: F405
97+
values=slope_colors, name="Slope Steepness"
98+
)
99+
# Vertical marker lines at landmarks using geom_segment
100+
+ geom_segment( # noqa: F405
101+
data=segments_df,
102+
mapping=aes(x="x", y="yend", xend="x", yend="y"), # noqa: F405
103+
color="#AAAAAA",
104+
size=0.6,
105+
linetype="dashed",
106+
inherit_aes=False,
107+
)
108+
# Landmark points — white filled circles with colored border
109+
+ geom_point( # noqa: F405
110+
data=landmarks_df,
111+
mapping=aes(x="distance", y="elevation"), # noqa: F405
112+
size=7,
113+
color="#306998",
114+
fill="white",
115+
shape=21,
116+
stroke=2.5,
117+
inherit_aes=False,
118+
tooltips=layer_tooltips() # noqa: F405
119+
.line("@name")
120+
.line("Elevation: @elevation m")
121+
.line("Distance: @distance km"),
122+
)
123+
# Connector lines from labels to landmark points
124+
+ geom_segment( # noqa: F405
125+
data=landmarks_df,
126+
mapping=aes(x="distance", y="elevation", xend="label_x", yend="label_y"), # noqa: F405
127+
color="#BBBBBB",
128+
size=0.4,
129+
linetype="dotted",
130+
inherit_aes=False,
131+
)
132+
# Landmark labels — sized to match other text elements, well-staggered
133+
+ geom_text( # noqa: F405
134+
data=landmarks_df,
135+
mapping=aes(x="label_x", y="label_y", label="name"), # noqa: F405
136+
size=14,
137+
color="#2C3E50",
138+
angle=40,
139+
hjust=0,
140+
fontface="bold",
141+
inherit_aes=False,
142+
)
143+
# Scales and labels
144+
+ scale_x_continuous( # noqa: F405
145+
name="Distance (km)", breaks=list(range(0, 121, 20)), limits=[-2, 150]
146+
)
147+
+ scale_y_continuous( # noqa: F405
148+
name="Elevation (m)", limits=[y_floor, y_max + 200], breaks=list(range(1000, y_max + 200, 200))
149+
)
150+
+ labs( # noqa: F405
151+
title="Alpine Trail Elevation Profile · area-elevation-profile · letsplot · pyplots.ai",
152+
subtitle="120 km hiking transect with 8 landmarks — vertical exaggeration ~10×",
153+
)
154+
+ ggsize(1600, 900) # noqa: F405
155+
+ theme_minimal() # noqa: F405
156+
+ theme( # noqa: F405
157+
axis_text=element_text(size=16, color="#555555"), # noqa: F405
158+
axis_title=element_text(size=20, color="#333333"), # noqa: F405
159+
plot_title=element_text(size=24, color="#1A1A2E", face="bold"), # noqa: F405
160+
plot_subtitle=element_text(size=16, color="#666666"), # noqa: F405
161+
legend_text=element_text(size=14), # noqa: F405
162+
legend_title=element_text(size=16, face="bold"), # noqa: F405
163+
legend_position="bottom",
164+
panel_grid_major_y=element_line(color="#E8E8E8", size=0.3), # noqa: F405
165+
panel_grid_major_x=element_blank(), # noqa: F405
166+
panel_grid_minor=element_blank(), # noqa: F405
167+
plot_margin=[40, 80, 20, 20],
168+
plot_background=element_rect(fill="#FAFAFA", color="#FAFAFA"), # noqa: F405
169+
panel_background=element_rect(fill="#FAFAFA", color="#FAFAFA"), # noqa: F405
170+
)
171+
)
172+
173+
# Save
174+
export_ggsave(plot, filename="plot.png", path=".", scale=3)
175+
export_ggsave(plot, filename="plot.html", path=".")

0 commit comments

Comments
 (0)