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