Skip to content

Commit 577135a

Browse files
Merge branch 'main' into implementation/area-elevation-profile/pygal
2 parents befee19 + b4f95fe commit 577135a

36 files changed

Lines changed: 8384 additions & 0 deletions
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
""" pyplots.ai
2+
area-elevation-profile: Terrain Elevation Profile Along Transect
3+
Library: altair 6.0.0 | Python 3.14.3
4+
Quality: 86/100 | Created: 2026-03-15
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
from PIL import Image
11+
12+
13+
# Data - Alpine hiking trail ~120 km with realistic terrain
14+
np.random.seed(42)
15+
num_points = 480
16+
distance = np.linspace(0, 120, num_points)
17+
18+
# Build realistic elevation profile with multiple peaks and valleys
19+
elevation = 900 + np.zeros(num_points)
20+
elevation += 1000 * np.sin(distance * np.pi / 60) ** 2
21+
elevation += 500 * np.sin(distance * np.pi / 30 + 1.2) ** 2
22+
elevation += 250 * np.sin(distance * np.pi / 15 + 0.5)
23+
elevation += np.cumsum(np.random.randn(num_points) * 3)
24+
elevation += np.random.randn(num_points) * 15
25+
kernel = np.ones(5) / 5
26+
elevation = np.convolve(elevation, kernel, mode="same")
27+
elevation = np.clip(elevation, 600, 2800)
28+
29+
df = pd.DataFrame({"distance": distance, "elevation": elevation})
30+
31+
# Landmarks along the trail with type for visual differentiation
32+
landmarks = pd.DataFrame(
33+
{
34+
"name": [
35+
"Grindelwald (Start)",
36+
"Bachsee Lake",
37+
"Faulhorn Summit",
38+
"Schynige Platte",
39+
"Kleine Scheidegg",
40+
"Männlichen Summit",
41+
"Wengen (End)",
42+
],
43+
"distance": [0.0, 18.0, 35.0, 55.0, 75.0, 95.0, 120.0],
44+
"type": ["town", "lake", "summit", "plateau", "pass", "summit", "town"],
45+
}
46+
)
47+
landmarks["elevation"] = np.interp(landmarks["distance"], distance, elevation)
48+
# Combined label: name + elevation on two lines
49+
landmarks["label"] = landmarks.apply(lambda r: f"{r['name']}\n{r['elevation']:.0f} m", axis=1)
50+
51+
# Tighter axis domains to eliminate wasted canvas space
52+
y_min = int(np.floor(elevation.min() / 100) * 100)
53+
54+
# Area chart - terrain silhouette with gradient fill
55+
area = (
56+
alt.Chart(df)
57+
.mark_area(
58+
line={"color": "#306998", "strokeWidth": 2.5},
59+
color=alt.Gradient(
60+
gradient="linear",
61+
stops=[
62+
alt.GradientStop(color="rgba(48, 105, 152, 0.05)", offset=0),
63+
alt.GradientStop(color="rgba(48, 105, 152, 0.22)", offset=0.3),
64+
alt.GradientStop(color="rgba(48, 105, 152, 0.55)", offset=1),
65+
],
66+
x1=1,
67+
x2=1,
68+
y1=1,
69+
y2=0,
70+
),
71+
)
72+
.encode(
73+
x=alt.X("distance:Q", title="Distance (km)", scale=alt.Scale(domain=[0, 120])),
74+
y=alt.Y("elevation:Q", title="Elevation (m)", scale=alt.Scale(domain=[y_min, 2800])),
75+
tooltip=[
76+
alt.Tooltip("distance:Q", title="Distance (km)", format=".1f"),
77+
alt.Tooltip("elevation:Q", title="Elevation (m)", format=".0f"),
78+
],
79+
)
80+
)
81+
82+
# Landmark vertical rules with color by type
83+
landmark_rules = (
84+
alt.Chart(landmarks)
85+
.mark_rule(strokeWidth=1, strokeDash=[5, 4], opacity=0.4)
86+
.encode(x="distance:Q", color=alt.condition(alt.datum.type == "summit", alt.value("#A0522D"), alt.value("#8B7355")))
87+
)
88+
89+
# Landmark points with shape encoding by type
90+
landmark_points = (
91+
alt.Chart(landmarks)
92+
.mark_point(size=160, filled=True, stroke="white", strokeWidth=1.5)
93+
.encode(
94+
x="distance:Q",
95+
y="elevation:Q",
96+
shape=alt.Shape(
97+
"type:N",
98+
legend=None,
99+
scale=alt.Scale(
100+
domain=["summit", "lake", "pass", "plateau", "town"],
101+
range=["triangle-up", "diamond", "cross", "square", "circle"],
102+
),
103+
),
104+
color=alt.Color(
105+
"type:N",
106+
legend=None,
107+
scale=alt.Scale(
108+
domain=["summit", "lake", "pass", "plateau", "town"],
109+
range=["#A0522D", "#4682B4", "#8B7355", "#6B8E23", "#555555"],
110+
),
111+
),
112+
)
113+
)
114+
115+
# Combined label layers (name + elevation) - 3 layers by alignment
116+
lm_start = landmarks[landmarks["distance"] == 0.0]
117+
lm_end = landmarks[landmarks["distance"] == 120.0]
118+
lm_mid = landmarks[(landmarks["distance"] > 0) & (landmarks["distance"] < 120)]
119+
120+
label_start = (
121+
alt.Chart(lm_start)
122+
.mark_text(
123+
align="left", dx=10, dy=-60, fontSize=16, fontWeight="bold", color="#3A3A3A", lineBreak="\n", lineHeight=20
124+
)
125+
.encode(x="distance:Q", y="elevation:Q", text="label:N")
126+
)
127+
label_end = (
128+
alt.Chart(lm_end)
129+
.mark_text(
130+
align="right", dx=-10, dy=-60, fontSize=16, fontWeight="bold", color="#3A3A3A", lineBreak="\n", lineHeight=20
131+
)
132+
.encode(x="distance:Q", y="elevation:Q", text="label:N")
133+
)
134+
135+
# Split mid landmarks by elevation to apply different dy offsets
136+
lm_mid_low = lm_mid[lm_mid["elevation"] < 1500].copy()
137+
lm_mid_high = lm_mid[lm_mid["elevation"] >= 1500].copy()
138+
139+
label_mid_low = (
140+
alt.Chart(lm_mid_low)
141+
.mark_text(align="center", dy=-60, fontSize=16, fontWeight="bold", lineBreak="\n", lineHeight=20)
142+
.encode(
143+
x="distance:Q",
144+
y="elevation:Q",
145+
text="label:N",
146+
color=alt.Color(
147+
"type:N",
148+
legend=None,
149+
scale=alt.Scale(
150+
domain=["summit", "lake", "pass", "plateau", "town"],
151+
range=["#6B3A1F", "#2E5E7E", "#5C4E3C", "#4A6317", "#3A3A3A"],
152+
),
153+
),
154+
)
155+
)
156+
label_mid_high = (
157+
alt.Chart(lm_mid_high)
158+
.mark_text(align="center", dy=-35, fontSize=16, fontWeight="bold", lineBreak="\n", lineHeight=20)
159+
.encode(
160+
x="distance:Q",
161+
y="elevation:Q",
162+
text="label:N",
163+
color=alt.Color(
164+
"type:N",
165+
legend=None,
166+
scale=alt.Scale(
167+
domain=["summit", "lake", "pass", "plateau", "town"],
168+
range=["#6B3A1F", "#2E5E7E", "#5C4E3C", "#4A6317", "#3A3A3A"],
169+
),
170+
),
171+
)
172+
)
173+
174+
# Compose layered chart
175+
chart = (
176+
alt.layer(area, landmark_rules, landmark_points, label_start, label_mid_low, label_mid_high, label_end)
177+
.properties(
178+
width=1600,
179+
height=900,
180+
title=alt.Title(
181+
"Bernese Oberland Trail · area-elevation-profile · altair · pyplots.ai",
182+
fontSize=28,
183+
subtitle="120 km hiking transect from Grindelwald to Wengen · Vertical exaggeration ~10×",
184+
subtitleFontSize=16,
185+
subtitleColor="#777777",
186+
anchor="start",
187+
offset=12,
188+
),
189+
)
190+
.configure(background="white")
191+
.configure_axis(
192+
labelFontSize=18, titleFontSize=22, gridOpacity=0.12, grid=True, domainColor="#999999", tickColor="#999999"
193+
)
194+
.configure_view(strokeWidth=0)
195+
)
196+
197+
# Save - render, trim bottom whitespace, resize to 4800x2700
198+
chart.save("plot.png", scale_factor=3.0)
199+
img = Image.open("plot.png").convert("RGB")
200+
201+
# Trim empty whitespace from bottom by scanning rows
202+
as_array = np.array(img)
203+
# Find last row that's not all-white (threshold 250 for anti-aliasing)
204+
row_has_content = np.any(as_array < 250, axis=(1, 2))
205+
last_content_row = np.max(np.where(row_has_content)) + 40 # 40px padding
206+
last_content_row = min(last_content_row, img.height)
207+
img = img.crop((0, 0, img.width, last_content_row))
208+
209+
# Resize to target 4800x2700
210+
img = img.resize((4800, 2700), Image.LANCZOS)
211+
img.save("plot.png")
212+
213+
chart.interactive().save("plot.html")

0 commit comments

Comments
 (0)