|
1 | 1 | """ anyplot.ai |
2 | 2 | area-mountain-panorama: Mountain Panorama Profile with Labeled Peaks |
3 | | -Library: altair 6.1.0 | Python 3.14.4 |
4 | | -Quality: 93/100 | Created: 2026-04-25 |
| 3 | +Library: altair 6.2.2 | Python 3.13.14 |
| 4 | +Quality: 84/100 | Updated: 2026-06-30 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import importlib |
8 | 8 | import os |
9 | 9 | import sys |
10 | 10 |
|
11 | 11 |
|
12 | | -# Drop script directory from sys.path so the `altair` package resolves, not this file |
| 12 | +# Drop script directory from sys.path so `altair` resolves the package, not this file |
13 | 13 | sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))] |
14 | 14 | alt = importlib.import_module("altair") |
15 | 15 | np = importlib.import_module("numpy") |
16 | 16 | pd = importlib.import_module("pandas") |
| 17 | +from PIL import Image |
17 | 18 |
|
18 | 19 |
|
19 | | -# Theme tokens (chrome flips with theme; data colors stay constant) |
| 20 | +# Theme tokens — chrome flips with theme; Imprint palette data colors stay constant |
20 | 21 | THEME = os.getenv("ANYPLOT_THEME", "light") |
21 | 22 | PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
22 | | -ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
23 | 23 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
24 | 24 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
25 | | -INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
26 | | -BRAND = "#009E73" # Okabe-Ito position 1 — silhouette fill (single data series) |
| 25 | +BRAND = "#009E73" # Imprint palette position 1 — silhouette fill (single data series) |
27 | 26 |
|
28 | | -# Theme-adaptive dusk sky gradient (chrome layer above the ridgeline; spec-authorized) |
29 | | -SKY_HORIZON = "#FFC58A" if THEME == "light" else "#5A3422" # warm dusk glow at ridgeline |
30 | | -SKY_MID = "#D89AA8" if THEME == "light" else "#2E1F35" # twilight rose / deep plum |
31 | | -SKY_ZENITH = "#5C5078" if THEME == "light" else "#0C0E1A" # evening blue / night sky |
| 27 | +# Theme-adaptive dusk sky gradient (spec-authorized chrome above the ridgeline) |
| 28 | +SKY_HORIZON = "#FFC58A" if THEME == "light" else "#5A3422" |
| 29 | +SKY_MID = "#D89AA8" if THEME == "light" else "#2E1F35" |
| 30 | +SKY_ZENITH = "#5C5078" if THEME == "light" else "#0C0E1A" |
| 31 | +# Alpenglow rim — warm gold / rose-copper at the sky-to-silhouette boundary |
| 32 | +ALPENGLOW = "#FFBA6A" if THEME == "light" else "#C88060" |
32 | 33 |
|
33 | | -# Data — Wallis (Valais, CH) panorama: 16 4000-m summits along a 180° sweep |
| 34 | +BASE_ELEV = 2950 |
| 35 | + |
| 36 | +# All 16 major Wallis (Valais, CH) summits across a 180° horizontal sweep. |
| 37 | +# left_slope / right_slope: m/degree for piecewise-linear tent flanks. |
34 | 38 | peaks = pd.DataFrame( |
35 | 39 | [ |
36 | | - ("Weisshorn", 4506, 9), |
37 | | - ("Zinalrothorn", 4221, 20), |
38 | | - ("Ober Gabelhorn", 4063, 30), |
39 | | - ("Dent Blanche", 4358, 42), |
40 | | - ("Matterhorn", 4478, 56), |
41 | | - ("Breithorn", 4164, 73), |
42 | | - ("Pollux", 4092, 81), |
43 | | - ("Castor", 4223, 88), |
44 | | - ("Liskamm", 4527, 97), |
45 | | - ("Monte Rosa", 4634, 109), |
46 | | - ("Strahlhorn", 4190, 122), |
47 | | - ("Rimpfischhorn", 4199, 132), |
48 | | - ("Allalinhorn", 4027, 140), |
49 | | - ("Alphubel", 4206, 148), |
50 | | - ("Täschhorn", 4491, 158), |
51 | | - ("Dom", 4545, 168), |
| 40 | + ("Weisshorn", 4506, 9, 280, 200), |
| 41 | + ("Zinalrothorn", 4221, 22, 180, 250), |
| 42 | + ("Ober Gabelhorn", 4063, 30, 220, 180), |
| 43 | + ("Dent Blanche", 4358, 42, 200, 300), |
| 44 | + ("Matterhorn", 4478, 56, 350, 280), # focal point — steepest flanks |
| 45 | + ("Breithorn", 4164, 72, 150, 160), |
| 46 | + ("Pollux", 4092, 83, 200, 170), |
| 47 | + ("Castor", 4223, 88, 180, 210), |
| 48 | + ("Liskamm", 4527, 97, 200, 180), |
| 49 | + ("Monte Rosa", 4634, 109, 180, 250), |
| 50 | + ("Strahlhorn", 4190, 122, 200, 180), |
| 51 | + ("Rimpfischhorn", 4199, 130, 220, 190), |
| 52 | + ("Allalinhorn", 4027, 137, 180, 200), |
| 53 | + ("Alphubel", 4206, 148, 160, 200), |
| 54 | + ("Täschhorn", 4491, 155, 250, 180), |
| 55 | + ("Dom", 4545, 168, 200, 280), |
52 | 56 | ], |
53 | | - columns=["name", "elevation_m", "angle_deg"], |
| 57 | + columns=["name", "elevation_m", "angle_deg", "left_slope", "right_slope"], |
54 | 58 | ) |
55 | 59 |
|
56 | | -# Skyline ridge — gaussians around named peaks plus naturalistic minor ridge texture |
| 60 | +# Ridgeline — piecewise-linear tent/triangle functions per spec. |
| 61 | +# Spec explicitly forbids Gaussian/bell-curve bumps; each summit uses two linear |
| 62 | +# flanks meeting at a sharp apex, with asymmetric slope steepness. |
57 | 63 | np.random.seed(42) |
58 | 64 | angles = np.linspace(-2, 182, 1500) |
59 | | -ridge_elev = 2950 + 110 * np.sin(angles * 0.11) + 35 * np.sin(angles * 0.43 + 1.1) |
60 | 65 |
|
61 | | -for _ in range(55): |
| 66 | +# Base ridge always at or above BASE_ELEV — positive-only sinusoidal undulation |
| 67 | +ridge_elev = BASE_ELEV + np.maximum(0, 70 * np.sin(angles * 0.12) + 22 * np.sin(angles * 0.47 + 1.1)) |
| 68 | + |
| 69 | +# Rocky inter-peak jaggedness: 65 random tent functions (NOT Gaussian) |
| 70 | +for _ in range(65): |
62 | 71 | pos = np.random.uniform(-2, 182) |
63 | | - height = np.random.uniform(150, 480) |
64 | | - width = np.random.uniform(1.4, 3.0) |
65 | | - ridge_elev = np.maximum(ridge_elev, 2950 + height * np.exp(-((angles - pos) ** 2) / (2 * width**2))) |
| 72 | + height = np.random.uniform(60, 320) |
| 73 | + lslope = np.random.uniform(60, 220) |
| 74 | + rslope = np.random.uniform(60, 220) |
| 75 | + tent = BASE_ELEV + height - np.where(angles <= pos, lslope * (pos - angles), rslope * (angles - pos)) |
| 76 | + ridge_elev = np.maximum(ridge_elev, np.maximum(BASE_ELEV, tent)) |
66 | 77 |
|
| 78 | +# Named peaks: steep asymmetric tent functions — sharp apex + linear flanks |
67 | 79 | for _, row in peaks.iterrows(): |
68 | | - height = row["elevation_m"] - 2950 |
69 | | - width = 2.0 + (row["elevation_m"] - 4000) * 0.0007 |
70 | | - ridge_elev = np.maximum(ridge_elev, 2950 + height * np.exp(-((angles - row["angle_deg"]) ** 2) / (2 * width**2))) |
| 80 | + pos, elev = row["angle_deg"], row["elevation_m"] |
| 81 | + tent = elev - np.where(angles <= pos, row["left_slope"] * (pos - angles), row["right_slope"] * (angles - pos)) |
| 82 | + ridge_elev = np.maximum(ridge_elev, np.maximum(BASE_ELEV, tent)) |
71 | 83 |
|
72 | 84 | ridge = pd.DataFrame({"angle_deg": angles, "elevation_m": ridge_elev}) |
73 | 85 |
|
74 | | -# Stagger label heights so adjacent peaks don't collide; Matterhorn lifted as focal summit |
75 | | -peaks = peaks.sort_values("angle_deg").reset_index(drop=True) |
76 | | -LABEL_HIGH = 5800 |
77 | | -LABEL_LOW = 5400 |
78 | | -peaks["label_y"] = [LABEL_HIGH if i % 2 == 0 else LABEL_LOW for i in range(len(peaks))] |
79 | | -peaks.loc[peaks["name"] == "Matterhorn", "label_y"] = 6000 |
80 | | -peaks["elev_label"] = peaks["elevation_m"].apply(lambda v: f"{v:.0f} m") |
| 86 | +# Four label tiers assigned by round-robin for maximum same-tier angular separation |
| 87 | +# (minimum ~33° within each tier, preventing label collision). |
| 88 | +# TIER_A (5300): Weisshorn(9°), Liskamm(97°), Allalinhorn(137°) |
| 89 | +# TIER_B (5100): Zinalrothorn(22°), Breithorn(72°), Monte Rosa(109°), Alphubel(148°) |
| 90 | +# TIER_C (4900): Ober Gabelhorn(30°), Pollux(83°), Strahlhorn(122°), Täschhorn(155°) |
| 91 | +# TIER_D (4700): Dent Blanche(42°), Castor(88°), Rimpfischhorn(130°), Dom(168°) |
| 92 | +# MATTERHORN SPECIAL (5500): strongest focal accent |
| 93 | +TIER_A, TIER_B, TIER_C, TIER_D, TIER_MAT = 5300, 5100, 4900, 4700, 5500 |
| 94 | +label_y_map = { |
| 95 | + "Weisshorn": TIER_A, |
| 96 | + "Zinalrothorn": TIER_B, |
| 97 | + "Ober Gabelhorn": TIER_C, |
| 98 | + "Dent Blanche": TIER_D, |
| 99 | + "Matterhorn": TIER_MAT, |
| 100 | + "Breithorn": TIER_B, |
| 101 | + "Pollux": TIER_C, |
| 102 | + "Castor": TIER_D, |
| 103 | + "Liskamm": TIER_A, |
| 104 | + "Monte Rosa": TIER_B, |
| 105 | + "Strahlhorn": TIER_C, |
| 106 | + "Rimpfischhorn": TIER_D, |
| 107 | + "Allalinhorn": TIER_A, |
| 108 | + "Alphubel": TIER_B, |
| 109 | + "Täschhorn": TIER_C, |
| 110 | + "Dom": TIER_D, |
| 111 | +} |
| 112 | +peaks["label_y"] = peaks["name"].map(label_y_map) |
| 113 | +peaks["elev_label"] = peaks["elevation_m"].apply(lambda v: f"{v} m") |
81 | 114 |
|
82 | 115 | matterhorn = peaks[peaks["name"] == "Matterhorn"] |
83 | 116 | others = peaks[peaks["name"] != "Matterhorn"] |
84 | 117 |
|
85 | | -# Shared scales / axis so all layers register on the same coordinate system |
| 118 | +# Coordinate system — only the sky layer carries the explicit scale + axis; |
| 119 | +# other layers share it implicitly via Vega-Lite layer scale resolution. |
86 | 120 | X_SCALE = alt.Scale(domain=[0, 180]) |
87 | | -Y_SCALE = alt.Scale(domain=[2900, 6300]) |
| 121 | +Y_SCALE = alt.Scale(domain=[2900, 5800]) |
88 | 122 | Y_AXIS = alt.Axis(values=[3000, 3500, 4000, 4500, 5000]) |
89 | 123 |
|
90 | | -# Sky — dusk vertical gradient covering the full plot area; silhouette will mask the lower half |
91 | | -sky_df = pd.DataFrame({"x_min": [0], "x_max": [180], "y_min": [2900], "y_max": [6300]}) |
| 124 | +# Layer 1: dusk sky gradient (vertical linear, zenith → ridge horizon) |
| 125 | +sky_df = pd.DataFrame({"x_min": [0], "x_max": [180], "y_min": [2900], "y_max": [5800]}) |
92 | 126 | sky = ( |
93 | 127 | alt.Chart(sky_df) |
94 | 128 | .mark_rect( |
|
113 | 147 | ) |
114 | 148 | ) |
115 | 149 |
|
116 | | -# Silhouette — brand-green photo-like fill; ridge stroke gives the snow-edge alpenglow line |
117 | | -silhouette = ( |
| 150 | +# Layer 2: mountain silhouette — brand-green filled area below the ridgeline |
| 151 | +silhouette = alt.Chart(ridge).mark_area(color=BRAND, opacity=1.0).encode(x="angle_deg:Q", y="elevation_m:Q") |
| 152 | + |
| 153 | +# Layer 3: alpenglow rim — warm glowing stroke at the sky-to-silhouette boundary |
| 154 | +alpenglow = ( |
118 | 155 | alt.Chart(ridge) |
119 | | - .mark_area(color=BRAND, line={"color": BRAND, "strokeWidth": 2.5}, opacity=1.0) |
| 156 | + .mark_line(color=ALPENGLOW, strokeWidth=3.5, opacity=0.88) |
120 | 157 | .encode(x="angle_deg:Q", y="elevation_m:Q") |
121 | 158 | ) |
122 | 159 |
|
123 | | -# Leader lines from summit up to label position (with tooltip for HTML hover) |
| 160 | +_tooltip = [alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")] |
| 161 | + |
| 162 | +# Layers 4–5: leader lines from summit apex to label anchor |
124 | 163 | leaders = ( |
125 | 164 | alt.Chart(others) |
126 | 165 | .mark_rule(strokeWidth=1.0, opacity=0.55, color=INK_SOFT) |
127 | | - .encode( |
128 | | - x="angle_deg:Q", |
129 | | - y="elevation_m:Q", |
130 | | - y2="label_y:Q", |
131 | | - tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")], |
132 | | - ) |
| 166 | + .encode(x="angle_deg:Q", y="elevation_m:Q", y2="label_y:Q", tooltip=_tooltip) |
133 | 167 | ) |
134 | 168 | matterhorn_leader = ( |
135 | 169 | alt.Chart(matterhorn) |
136 | | - .mark_rule(strokeWidth=2.0, opacity=0.9, color=INK) |
137 | | - .encode( |
138 | | - x="angle_deg:Q", |
139 | | - y="elevation_m:Q", |
140 | | - y2="label_y:Q", |
141 | | - tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")], |
142 | | - ) |
| 170 | + .mark_rule(strokeWidth=2.5, opacity=0.9, color=INK) |
| 171 | + .encode(x="angle_deg:Q", y="elevation_m:Q", y2="label_y:Q", tooltip=_tooltip) |
143 | 172 | ) |
144 | 173 |
|
145 | | -# Two-line peak labels at recommended sizes (name 18, elevation 15 — meets tick-floor) |
| 174 | +# Layers 6–7: center-aligned name/elevation labels for all non-Matterhorn peaks |
146 | 175 | name_labels = ( |
147 | 176 | alt.Chart(others) |
148 | | - .mark_text(align="center", baseline="bottom", fontSize=18, fontWeight="bold", color=INK, dy=-26) |
149 | | - .encode( |
150 | | - x="angle_deg:Q", |
151 | | - y="label_y:Q", |
152 | | - text="name:N", |
153 | | - tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")], |
154 | | - ) |
| 177 | + .mark_text(align="center", baseline="bottom", fontSize=10, fontWeight="bold", color=INK, dy=-22) |
| 178 | + .encode(x="angle_deg:Q", y="label_y:Q", text="name:N", tooltip=_tooltip) |
155 | 179 | ) |
156 | 180 | elev_labels = ( |
157 | 181 | alt.Chart(others) |
158 | | - .mark_text(align="center", baseline="bottom", fontSize=15, color=INK_SOFT, dy=-8) |
159 | | - .encode( |
160 | | - x="angle_deg:Q", |
161 | | - y="label_y:Q", |
162 | | - text="elev_label:N", |
163 | | - tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")], |
164 | | - ) |
| 182 | + .mark_text(align="center", baseline="bottom", fontSize=10, color=INK_SOFT, dy=-6) |
| 183 | + .encode(x="angle_deg:Q", y="label_y:Q", text="elev_label:N", tooltip=_tooltip) |
165 | 184 | ) |
166 | 185 |
|
167 | | -# Matterhorn focal accent: notably larger label so the anchor summit reads as the composition's focus |
| 186 | +# Layers 8–9: Matterhorn focal accent — larger font, heavier weight, composition anchor |
168 | 187 | matterhorn_name = ( |
169 | 188 | alt.Chart(matterhorn) |
170 | | - .mark_text(align="center", baseline="bottom", fontSize=26, fontWeight="bold", color=INK, dy=-30) |
171 | | - .encode( |
172 | | - x="angle_deg:Q", |
173 | | - y="label_y:Q", |
174 | | - text="name:N", |
175 | | - tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")], |
176 | | - ) |
| 189 | + .mark_text(align="center", baseline="bottom", fontSize=15, fontWeight="bold", color=INK, dy=-28) |
| 190 | + .encode(x="angle_deg:Q", y="label_y:Q", text="name:N", tooltip=_tooltip) |
177 | 191 | ) |
178 | 192 | matterhorn_elev = ( |
179 | 193 | alt.Chart(matterhorn) |
180 | | - .mark_text(align="center", baseline="bottom", fontSize=18, fontWeight="bold", color=INK_SOFT, dy=-8) |
181 | | - .encode( |
182 | | - x="angle_deg:Q", |
183 | | - y="label_y:Q", |
184 | | - text="elev_label:N", |
185 | | - tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")], |
186 | | - ) |
| 194 | + .mark_text(align="center", baseline="bottom", fontSize=12, fontWeight="bold", color=INK_SOFT, dy=-8) |
| 195 | + .encode(x="angle_deg:Q", y="label_y:Q", text="elev_label:N", tooltip=_tooltip) |
187 | 196 | ) |
188 | 197 |
|
| 198 | +title_str = "Wallis Panorama · area-mountain-panorama · python · altair · anyplot.ai" |
| 199 | +n = len(title_str) |
| 200 | +ratio = 67 / n if n > 67 else 1.0 |
| 201 | +title_fs = max(11, round(16 * ratio)) |
| 202 | + |
| 203 | +# height=190: vl-convert with explicit Y_SCALE on the sky anchor layer adds ~47 CSS px |
| 204 | +# of Y overhead per 190 CSS px (source height ≈ 1500px pre-pad, ≤1800px with title). |
| 205 | +# DO NOT increase to ≥210 — overhead scales with height and tips over 1800 source px. |
189 | 206 | chart = ( |
190 | | - (sky + silhouette + leaders + matterhorn_leader + name_labels + elev_labels + matterhorn_name + matterhorn_elev) |
| 207 | + ( |
| 208 | + sky |
| 209 | + + silhouette |
| 210 | + + alpenglow |
| 211 | + + leaders |
| 212 | + + matterhorn_leader |
| 213 | + + name_labels |
| 214 | + + elev_labels |
| 215 | + + matterhorn_name |
| 216 | + + matterhorn_elev |
| 217 | + ) |
191 | 218 | .properties( |
192 | | - width=1600, |
193 | | - height=900, |
| 219 | + width=620, |
| 220 | + height=190, |
194 | 221 | title=alt.Title( |
195 | | - "Wallis Panorama · area-mountain-panorama · altair · anyplot.ai", |
| 222 | + title_str, |
196 | 223 | subtitle="Sixteen 4000-m summits along a 180° horizontal sweep, Valais Alps", |
197 | 224 | subtitleColor=INK_SOFT, |
198 | | - subtitleFontSize=18, |
199 | | - fontSize=28, |
| 225 | + subtitleFontSize=13, |
| 226 | + fontSize=title_fs, |
200 | 227 | anchor="start", |
201 | | - offset=18, |
| 228 | + offset=12, |
202 | 229 | color=INK, |
203 | 230 | ), |
204 | 231 | background=PAGE_BG, |
|
211 | 238 | gridOpacity=0.0, |
212 | 239 | labelColor=INK_SOFT, |
213 | 240 | titleColor=INK, |
214 | | - labelFontSize=18, |
215 | | - titleFontSize=22, |
216 | | - tickSize=8, |
| 241 | + labelFontSize=10, |
| 242 | + titleFontSize=12, |
| 243 | + tickSize=5, |
217 | 244 | ) |
218 | 245 | ) |
219 | 246 |
|
220 | | -chart.save(f"plot-{THEME}.png", scale_factor=3.0) |
| 247 | +chart.save(f"plot-{THEME}.png", scale_factor=4.0) |
221 | 248 | chart.save(f"plot-{THEME}.html") |
| 249 | + |
| 250 | +# Pad-only to exact 3200×1800 target — altair.md Canvas rule (never crop) |
| 251 | +TW, TH = 3200, 1800 |
| 252 | +_img = Image.open(f"plot-{THEME}.png").convert("RGB") |
| 253 | +_w, _h = _img.size |
| 254 | +if _w > TW or _h > TH: |
| 255 | + raise SystemExit( |
| 256 | + f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. " |
| 257 | + f"Shrink chart .properties(width=, height=) values and re-render." |
| 258 | + ) |
| 259 | +if _w < TW or _h < TH: |
| 260 | + _canvas = Image.new("RGB", (TW, TH), PAGE_BG) |
| 261 | + _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2)) |
| 262 | + _canvas.save(f"plot-{THEME}.png") |
0 commit comments