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