Skip to content

Commit 39b747c

Browse files
feat(pygal): implement area-mountain-panorama (#5407)
## Implementation: `area-mountain-panorama` - python/pygal Implements the **python/pygal** version of `area-mountain-panorama`. **File:** `plots/area-mountain-panorama/implementations/python/pygal.py` **Parent Issue:** #5365 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24941215162)* --------- 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 c7f85c4 commit 39b747c

2 files changed

Lines changed: 559 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
""" anyplot.ai
2+
area-mountain-panorama: Mountain Panorama Profile with Labeled Peaks
3+
Library: pygal 3.1.0 | Python 3.14.4
4+
Quality: 87/100 | Created: 2026-04-25
5+
"""
6+
7+
import os
8+
import re
9+
import sys
10+
11+
12+
# Script filename shadows the installed `pygal` package when run as `python pygal.py`;
13+
# dropping the script directory from sys.path lets the real package resolve.
14+
sys.path.pop(0)
15+
16+
import cairosvg # noqa: E402
17+
import numpy as np # noqa: E402
18+
import pygal # noqa: E402
19+
from pygal.style import Style # noqa: E402
20+
21+
22+
# Theme tokens
23+
THEME = os.getenv("ANYPLOT_THEME", "light")
24+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
25+
SKY_TOP = "#E8C8A0" if THEME == "light" else "#252D40"
26+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
27+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
28+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
29+
BRAND = "#009E73"
30+
31+
# Data — Wallis (Valais) summit panorama, ordered W → E
32+
peaks = [
33+
("Weisshorn", 12, 4506),
34+
("Zinalrothorn", 30, 4221),
35+
("Ober Gabelhorn", 45, 4063),
36+
("Dent Blanche", 58, 4358),
37+
("Dent d'Hérens", 76, 4171),
38+
("Matterhorn", 92, 4478),
39+
("Breithorn", 120, 4164),
40+
("Pollux", 132, 4092),
41+
("Castor", 139, 4223),
42+
("Liskamm", 152, 4527),
43+
("Monte Rosa", 170, 4634),
44+
("Strahlhorn", 192, 4190),
45+
("Rimpfischhorn", 204, 4199),
46+
("Allalinhorn", 215, 4027),
47+
("Alphubel", 225, 4206),
48+
("Täschhorn", 236, 4491),
49+
("Dom", 250, 4545),
50+
]
51+
52+
# Skyline construction
53+
np.random.seed(42)
54+
angle = np.linspace(0, 262, 1200)
55+
56+
# Base ridge: smoothed random walk in the 3000–3700 m belt (foothills + minor cols)
57+
walk = np.cumsum(np.random.randn(len(angle)) * 1.5)
58+
sigma_walk = 14
59+
g = np.arange(-3 * sigma_walk, 3 * sigma_walk + 1)
60+
kernel_walk = np.exp(-(g**2) / (2 * sigma_walk**2))
61+
walk = np.convolve(walk, kernel_walk / kernel_walk.sum(), mode="same")
62+
walk = (walk - walk.min()) / (walk.max() - walk.min())
63+
ridge = 3000 + walk * 700
64+
65+
# Major summits as Gaussian peaks (max-combined for the visible silhouette)
66+
for _, pos, elev in peaks:
67+
width = 5.5 + (elev - 4000) / 130
68+
bump = (elev - 2700) * np.exp(-((angle - pos) ** 2) / (2 * width**2))
69+
ridge = np.maximum(ridge, 2700 + bump)
70+
71+
# Light final smoothing of the combined ridge
72+
sigma_ridge = 0.8
73+
g = np.arange(-3, 4)
74+
kernel_ridge = np.exp(-(g**2) / (2 * sigma_ridge**2))
75+
ridge = np.convolve(ridge, kernel_ridge / kernel_ridge.sum(), mode="same")
76+
77+
# Canvas + plot box
78+
CANVAS_W, CANVAS_H = 4800, 2700
79+
MARGIN_L, MARGIN_R = 220, 120
80+
MARGIN_T, MARGIN_B = 200, 160
81+
82+
Y_FLOOR, Y_CEIL = 2500, 6050
83+
X_MIN, X_MAX = 0, 262
84+
85+
font = "DejaVu Sans, Helvetica, Arial, sans-serif"
86+
87+
# Pygal Style — first colour is brand green; muted foreground used for axis chrome
88+
custom_style = Style(
89+
background=PAGE_BG,
90+
plot_background="transparent",
91+
foreground=INK_SOFT,
92+
foreground_strong=INK,
93+
foreground_subtle=INK_MUTED,
94+
colors=(BRAND, INK if THEME == "light" else "#F0EFE8"),
95+
font_family=font,
96+
title_font_family=font,
97+
label_font_family=font,
98+
major_label_font_family=font,
99+
tooltip_font_family=font,
100+
tooltip_font_size=30,
101+
legend_font_size=34,
102+
stroke_width=2,
103+
opacity=".95",
104+
opacity_hover=".75",
105+
transition="200ms ease-in",
106+
)
107+
108+
# Pygal XY chart with fill=True draws the ridge silhouette as the brand-green
109+
# series. Peak summits are added as a second non-stroked series so pygal
110+
# renders interactive hover dots over each named summit in the HTML export.
111+
chart = pygal.XY(
112+
width=CANVAS_W,
113+
height=CANVAS_H,
114+
style=custom_style,
115+
show_legend=False,
116+
show_x_labels=False,
117+
show_y_labels=False,
118+
show_x_guides=False,
119+
show_y_guides=False,
120+
margin_left=MARGIN_L,
121+
margin_right=MARGIN_R,
122+
margin_top=MARGIN_T,
123+
margin_bottom=MARGIN_B,
124+
xrange=(X_MIN, X_MAX),
125+
range=(Y_FLOOR, Y_CEIL),
126+
fill=True,
127+
show_dots=False,
128+
stroke_style={"width": 2},
129+
truncate_label=-1,
130+
)
131+
132+
ridge_data = [{"value": (float(a), float(e))} for a, e in zip(angle, ridge, strict=True)]
133+
chart.add("Skyline", ridge_data)
134+
135+
peak_data = [{"value": (float(pos), float(elev)), "label": f"{name} · {elev:,} m"} for name, pos, elev in peaks]
136+
chart.add("Peaks", peak_data, show_dots=True, dots_size=10, stroke=False, fill=False)
137+
138+
# Render pygal first; back-compute its plot box from the two extreme summit dots
139+
# so post-processed chrome (sky gradient, leader lines, labels) aligns with the
140+
# interactive markers pygal drew.
141+
base_svg = chart.render(is_unicode=True)
142+
143+
dot_re = re.compile(r'<circle cx="([-\d.]+)" cy="([-\d.]+)"[^>]*class="dot')
144+
dots = [(float(cx), float(cy)) for cx, cy in dot_re.findall(base_svg)]
145+
(p1x, p1y) = dots[0]
146+
(p2x, p2y) = dots[-1]
147+
ref_a, ref_b = peaks[0], peaks[-1]
148+
x_scale = (p2x - p1x) / (ref_b[1] - ref_a[1])
149+
x_off = p1x - ref_a[1] * x_scale
150+
y_scale = (p2y - p1y) / (ref_b[2] - ref_a[2])
151+
y_off = p1y - ref_a[2] * y_scale
152+
153+
154+
def to_svg(ax, ay):
155+
return MARGIN_L + ax * x_scale + x_off, MARGIN_T + ay * y_scale + y_off
156+
157+
158+
plot_x_left, _ = to_svg(X_MIN, Y_CEIL)
159+
plot_x_right, plot_y_bottom = to_svg(X_MAX, Y_FLOOR)
160+
_, plot_y_top = to_svg(X_MIN, Y_CEIL)
161+
plot_w = plot_x_right - plot_x_left
162+
plot_h = plot_y_bottom - plot_y_top
163+
164+
# Sky gradient + custom labels go BEFORE pygal's plot group so the silhouette
165+
# and interactive dots stay on top.
166+
svg_parts = [
167+
f'<rect x="0" y="0" width="{CANVAS_W}" height="{CANVAS_H}" fill="{PAGE_BG}" stroke="none"/>',
168+
f"""<defs>
169+
<linearGradient id="skyGrad" x1="0" y1="0" x2="0" y2="1">
170+
<stop offset="0%" stop-color="{SKY_TOP}"/>
171+
<stop offset="100%" stop-color="{PAGE_BG}"/>
172+
</linearGradient>
173+
</defs>""",
174+
f'<rect x="{plot_x_left:.2f}" y="{plot_y_top:.2f}" '
175+
f'width="{plot_w:.2f}" height="{plot_h:.2f}" fill="url(#skyGrad)" stroke="none"/>',
176+
]
177+
178+
# Title
179+
svg_parts.append(
180+
f'<text x="{CANVAS_W / 2:.2f}" y="110" text-anchor="middle" fill="{INK}" '
181+
f'style="font-size:62px;font-weight:500;font-family:{font}">'
182+
f"Wallis Alps · area-mountain-panorama · pygal · anyplot.ai</text>"
183+
)
184+
185+
# Y-axis title + tick labels (drawn manually since pygal labels are off)
186+
y_ticks = [3000, 3500, 4000, 4500, 5000, 5500, 6000]
187+
for v in y_ticks:
188+
_, ty = to_svg(X_MIN, v)
189+
svg_parts.append(
190+
f'<text x="{plot_x_left - 28:.2f}" y="{ty + 14:.2f}" text-anchor="end" '
191+
f'fill="{INK_SOFT}" style="font-size:36px;font-family:{font}">{v:,}</text>'
192+
)
193+
svg_parts.append(
194+
f'<line x1="{plot_x_left:.2f}" y1="{ty:.2f}" x2="{plot_x_right:.2f}" y2="{ty:.2f}" '
195+
f'stroke="{INK}" stroke-opacity="0.10" stroke-width="1.2"/>'
196+
)
197+
198+
y_title_x = plot_x_left - 140
199+
y_title_y = plot_y_top + plot_h / 2
200+
svg_parts.append(
201+
f'<text x="{y_title_x:.2f}" y="{y_title_y:.2f}" text-anchor="middle" fill="{INK}" '
202+
f'style="font-size:42px;font-family:{font}" '
203+
f'transform="rotate(-90, {y_title_x:.2f}, {y_title_y:.2f})">Elevation (m)</text>'
204+
)
205+
206+
# Compass bearings on x-axis
207+
compass_ticks = [(10, "W"), (65, "SW"), (120, "S"), (180, "SE"), (245, "E")]
208+
for ang, label in compass_ticks:
209+
tx, _ = to_svg(ang, Y_FLOOR)
210+
svg_parts.append(
211+
f'<text x="{tx:.2f}" y="{plot_y_bottom + 60:.2f}" text-anchor="middle" '
212+
f'fill="{INK_SOFT}" style="font-size:36px;font-family:{font}">{label}</text>'
213+
)
214+
215+
# L-shaped frame (left + bottom)
216+
svg_parts.append(
217+
f'<line x1="{plot_x_left:.2f}" y1="{plot_y_top:.2f}" x2="{plot_x_left:.2f}" y2="{plot_y_bottom:.2f}" '
218+
f'stroke="{INK_SOFT}" stroke-width="2"/>'
219+
)
220+
svg_parts.append(
221+
f'<line x1="{plot_x_left:.2f}" y1="{plot_y_bottom:.2f}" x2="{plot_x_right:.2f}" y2="{plot_y_bottom:.2f}" '
222+
f'stroke="{INK_SOFT}" stroke-width="2"/>'
223+
)
224+
225+
# Peak labels — staggered across three vertical tiers, with thin leader lines.
226+
# Matterhorn is treated as the focal anchor (heavier weight + colour).
227+
LABEL_TIERS = [4870, 5180, 5490]
228+
for i, (name, pos, elev) in enumerate(peaks):
229+
is_focal = name == "Matterhorn"
230+
tier_y_data = LABEL_TIERS[i % 3]
231+
sx, sy_summit = to_svg(pos, elev)
232+
_, sy_label = to_svg(pos, tier_y_data)
233+
234+
leader_color = INK if is_focal else INK_SOFT
235+
leader_op = 0.85 if is_focal else 0.45
236+
leader_w = 2.0 if is_focal else 1.2
237+
238+
svg_parts.append(
239+
f'<line x1="{sx:.2f}" y1="{sy_summit - 6:.2f}" x2="{sx:.2f}" y2="{sy_label + 18:.2f}" '
240+
f'stroke="{leader_color}" stroke-opacity="{leader_op}" stroke-width="{leader_w}"/>'
241+
)
242+
243+
name_size = 34 if is_focal else 28
244+
elev_size = 26 if is_focal else 22
245+
name_weight = "700" if is_focal else "600"
246+
name_color = INK if is_focal else INK_SOFT
247+
elev_color = INK_SOFT if is_focal else INK_MUTED
248+
249+
svg_parts.append(
250+
f'<text x="{sx:.2f}" y="{sy_label:.2f}" text-anchor="middle" fill="{name_color}" '
251+
f'style="font-size:{name_size}px;font-weight:{name_weight};font-family:{font}">{name}</text>'
252+
)
253+
svg_parts.append(
254+
f'<text x="{sx:.2f}" y="{sy_label + name_size + 6:.2f}" text-anchor="middle" fill="{elev_color}" '
255+
f'style="font-size:{elev_size}px;font-family:{font}">{elev:,} m</text>'
256+
)
257+
258+
# Subtitle
259+
svg_parts.append(
260+
f'<text x="{CANVAS_W / 2:.2f}" y="170" text-anchor="middle" fill="{INK_SOFT}" '
261+
f'style="font-size:32px;font-family:{font}">'
262+
f"Sixteen 4 000 m peaks of the Pennine Alps, viewed W → E from a single vantage</text>"
263+
)
264+
265+
custom_svg = "\n".join(svg_parts)
266+
267+
# Inject the custom chrome BEFORE pygal's plot group so the silhouette and
268+
# interactive markers stay layered on top of the sky gradient.
269+
plot_group_idx = base_svg.find('class="plot"')
270+
if plot_group_idx != -1:
271+
insert_idx = base_svg.rfind("<g", 0, plot_group_idx)
272+
output_svg = base_svg[:insert_idx] + custom_svg + "\n" + base_svg[insert_idx:]
273+
else:
274+
output_svg = base_svg.replace("</svg>", f"{custom_svg}\n</svg>")
275+
276+
cairosvg.svg2png(bytestring=output_svg.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=CANVAS_W)
277+
278+
html_content = f"""<!DOCTYPE html>
279+
<html>
280+
<head>
281+
<meta charset="utf-8">
282+
<title>area-mountain-panorama · pygal · anyplot.ai</title>
283+
<style>
284+
body {{ margin: 0; background: {PAGE_BG}; display: flex;
285+
justify-content: center; align-items: center; min-height: 100vh; }}
286+
.chart {{ max-width: 100%; height: auto; }}
287+
</style>
288+
</head>
289+
<body>
290+
<figure class="chart">
291+
{output_svg}
292+
</figure>
293+
</body>
294+
</html>
295+
"""
296+
297+
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
298+
f.write(html_content)

0 commit comments

Comments
 (0)