|
| 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