Skip to content

Commit efdb938

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

2 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
""" pyplots.ai
2+
area-elevation-profile: Terrain Elevation Profile Along Transect
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 86/100 | Created: 2026-03-15
5+
"""
6+
7+
import re
8+
import xml.etree.ElementTree as ET
9+
10+
import cairosvg
11+
import numpy as np
12+
import pygal
13+
from pygal.style import Style
14+
15+
16+
# Data - Alpine hiking trail elevation profile (120 km transect, sampled every ~1 km)
17+
np.random.seed(42)
18+
19+
distances_km = np.linspace(0, 120, 200)
20+
21+
# Build realistic terrain with multiple peaks and valleys
22+
base_terrain = (
23+
800
24+
+ 600 * np.sin(distances_km * np.pi / 40) ** 2
25+
+ 400 * np.sin(distances_km * np.pi / 25 + 1.2) ** 2
26+
+ 300 * np.exp(-((distances_km - 55) ** 2) / 80)
27+
+ 500 * np.exp(-((distances_km - 85) ** 2) / 120)
28+
- 200 * np.exp(-((distances_km - 30) ** 2) / 50)
29+
)
30+
noise = np.random.normal(0, 25, len(distances_km))
31+
elevation_m = base_terrain + noise
32+
elevation_m = np.maximum(elevation_m, 650)
33+
34+
# Landmarks along the trail with types for visual differentiation
35+
landmarks = [
36+
{"name": "Grindelwald", "km": 0, "type": "town"},
37+
{"name": "Kleine Scheidegg", "km": 22, "type": "pass"},
38+
{"name": "Lauterbrunnen", "km": 38, "type": "valley"},
39+
{"name": "Mürren", "km": 55, "type": "summit"},
40+
{"name": "Blüemlisalp Hut", "km": 72, "type": "hut"},
41+
{"name": "Hohtürli Pass", "km": 85, "type": "pass"},
42+
{"name": "Kandersteg", "km": 120, "type": "town"},
43+
]
44+
45+
# Get elevation at landmark positions
46+
for lm in landmarks:
47+
idx = np.argmin(np.abs(distances_km - lm["km"]))
48+
lm["elev"] = float(elevation_m[idx])
49+
50+
# Visual styling per landmark type
51+
TYPE_COLORS = {
52+
"summit": "#b5342b", # deep red for highest point
53+
"pass": "#c45a00", # orange for mountain passes
54+
"hut": "#7b4ea0", # purple for alpine huts (distinct from orange)
55+
"valley": "#2a7f3f", # green for valley floors
56+
"town": "#306998", # blue for towns/settlements
57+
}
58+
59+
# Custom style with refined palette
60+
custom_style = Style(
61+
background="white",
62+
plot_background="white",
63+
foreground="#2d2d2d",
64+
foreground_strong="#2d2d2d",
65+
foreground_subtle="#e8e8e8",
66+
colors=("#4a7fb5", "#d4690e"),
67+
font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
68+
title_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
69+
title_font_size=48,
70+
label_font_size=36,
71+
major_label_font_size=34,
72+
value_font_size=28,
73+
legend_font_size=30,
74+
legend_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
75+
label_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
76+
major_label_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
77+
value_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
78+
opacity=0.65,
79+
opacity_hover=0.75,
80+
guide_stroke_color="#e8e8e8",
81+
guide_stroke_dasharray="4,4",
82+
major_guide_stroke_color="#d0d0d0",
83+
major_guide_stroke_dasharray="6,4",
84+
stroke_opacity=1.0,
85+
stroke_opacity_hover=1.0,
86+
tooltip_font_size=26,
87+
tooltip_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
88+
tooltip_border_radius=8,
89+
)
90+
91+
# Chart - tightened y-range to frame data better (peak ~2009m)
92+
chart = pygal.XY(
93+
width=4800,
94+
height=2700,
95+
title="Bernese Oberland Traverse · area-elevation-profile · pygal · pyplots.ai",
96+
x_title="Distance (km)",
97+
y_title="Elevation (m)",
98+
style=custom_style,
99+
fill=True,
100+
show_dots=False,
101+
stroke_style={"width": 4},
102+
show_y_guides=True,
103+
show_x_guides=False,
104+
show_legend=True,
105+
legend_at_bottom=True,
106+
legend_box_size=24,
107+
value_formatter=lambda x: f"{x:,.0f} m",
108+
x_value_formatter=lambda x: f"{x:.0f} km",
109+
interpolate="cubic",
110+
interpolation_precision=300,
111+
min_scale=5,
112+
max_scale=10,
113+
margin_bottom=80,
114+
margin_left=100,
115+
margin_right=140,
116+
margin_top=60,
117+
spacing=12,
118+
tooltip_fancy_mode=True,
119+
range=(500, 2200),
120+
xrange=(0, 128),
121+
show_minor_x_labels=False,
122+
show_minor_y_labels=False,
123+
truncate_legend=-1,
124+
dynamic_print_values=True,
125+
print_values=False,
126+
show_x_labels=True,
127+
show_y_labels=True,
128+
x_labels_major_count=7,
129+
y_labels_major_count=6,
130+
)
131+
132+
# Elevation profile as filled XY series
133+
profile_data = [
134+
{"value": (float(d), float(e)), "label": f"{d:.1f} km — {e:.0f} m"}
135+
for d, e in zip(distances_km, elevation_m, strict=True)
136+
]
137+
chart.add("Elevation Profile", profile_data)
138+
139+
# Landmark markers as separate series with visible dots
140+
landmark_data = [
141+
{"value": (float(lm["km"]), lm["elev"]), "label": f"{lm['name']}{lm['elev']:.0f} m"} for lm in landmarks
142+
]
143+
chart.add("Landmarks", landmark_data, fill=False, show_dots=True, dots_size=16, stroke=False)
144+
145+
# Save interactive HTML version with pygal's native tooltip support
146+
chart.render_to_file("plot.html")
147+
148+
# Render SVG for annotation post-processing
149+
svg_bytes = chart.render()
150+
SVG_NS = "http://www.w3.org/2000/svg"
151+
ET.register_namespace("", SVG_NS)
152+
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
153+
root = ET.fromstring(svg_bytes)
154+
155+
# Build parent map and find landmark circles (only dots from the Landmarks series)
156+
parent_map = {child: parent for parent in root.iter() for child in parent}
157+
circles = sorted(
158+
[c for c in root.iter(f"{{{SVG_NS}}}circle") if float(c.get("r", "0")) > 3], key=lambda c: float(c.get("cx", "0"))
159+
)
160+
161+
# Find plot bottom from horizontal guide paths (format: "M0.0 Y h...")
162+
163+
plot_bottom_y = 0
164+
for path in root.iter(f"{{{SVG_NS}}}path"):
165+
cls = path.get("class", "")
166+
d = path.get("d", "")
167+
if "guide" in cls and " h" in d:
168+
m = re.match(r"M[\d.]+ ([\d.]+) h", d)
169+
if m:
170+
plot_bottom_y = max(plot_bottom_y, float(m.group(1)))
171+
172+
# Label positioning: stagger closely-spaced labels to avoid overlap
173+
label_offsets = {
174+
"Grindelwald": -42,
175+
"Kleine Scheidegg": -42,
176+
"Lauterbrunnen": -42,
177+
"Mürren": -52,
178+
"Blüemlisalp Hut": -70,
179+
"Hohtürli Pass": -42,
180+
"Kandersteg": -42,
181+
}
182+
183+
ns = f"{{{SVG_NS}}}"
184+
for i, circle in enumerate(circles[: len(landmarks)]):
185+
parent_elem = parent_map.get(circle)
186+
if parent_elem is None:
187+
continue
188+
189+
cx, cy = float(circle.get("cx", "0")), float(circle.get("cy", "0"))
190+
lm = landmarks[i]
191+
lm_color = TYPE_COLORS.get(lm["type"], "#c45a00")
192+
y_off = label_offsets.get(lm["name"], -42)
193+
194+
# Text anchor based on position
195+
if i == 0:
196+
anchor, dx = "start", 10
197+
elif i == len(landmarks) - 1:
198+
anchor, dx = "end", -10
199+
else:
200+
anchor, dx = "middle", 0
201+
202+
# Vertical dashed marker line
203+
vline = ET.SubElement(parent_elem, f"{ns}line")
204+
for attr, val in [("x1", cx), ("y1", cy), ("x2", cx), ("y2", plot_bottom_y)]:
205+
vline.set(attr, f"{val:.1f}")
206+
vline.set("stroke", lm_color)
207+
vline.set("stroke-width", "2")
208+
vline.set("stroke-dasharray", "8,5")
209+
vline.set("opacity", "0.55")
210+
211+
# Landmark name (bold, colored by type)
212+
name_el = ET.SubElement(parent_elem, f"{ns}text")
213+
name_el.set("x", f"{cx + dx:.1f}")
214+
name_el.set("y", f"{cy + y_off:.1f}")
215+
name_el.set("text-anchor", anchor)
216+
name_el.set("font-size", "32")
217+
name_el.set("font-family", "DejaVu Sans, Helvetica, Arial, sans-serif")
218+
name_el.set("fill", lm_color)
219+
name_el.set("font-weight", "bold")
220+
name_el.text = lm["name"]
221+
222+
# Elevation label (gray, below name)
223+
elev_el = ET.SubElement(parent_elem, f"{ns}text")
224+
elev_el.set("x", f"{cx + dx:.1f}")
225+
elev_el.set("y", f"{cy + y_off + 24:.1f}")
226+
elev_el.set("text-anchor", anchor)
227+
elev_el.set("font-size", "26")
228+
elev_el.set("font-family", "DejaVu Sans, Helvetica, Arial, sans-serif")
229+
elev_el.set("fill", "#555555")
230+
elev_el.text = f"{lm['elev']:.0f} m"
231+
232+
# Add vertical exaggeration note (spec requirement) - positioned bottom-right
233+
note_el = ET.SubElement(root, f"{ns}text")
234+
note_el.set("x", "4650")
235+
note_el.set("y", f"{plot_bottom_y + 48:.0f}")
236+
note_el.set("text-anchor", "end")
237+
note_el.set("font-size", "28")
238+
note_el.set("font-family", "DejaVu Sans, Helvetica, Arial, sans-serif")
239+
note_el.set("fill", "#555555")
240+
note_el.set("font-style", "italic")
241+
note_el.text = "Vertical exaggeration ~10\u00d7 for terrain visibility"
242+
243+
# Convert to PNG
244+
cairosvg.svg2png(bytestring=ET.tostring(root, encoding="unicode").encode("utf-8"), write_to="plot.png")

0 commit comments

Comments
 (0)