Skip to content

Commit 4df09b2

Browse files
feat(pygal): implement contour-map-geographic (#3939)
## Implementation: `contour-map-geographic` - pygal Implements the **pygal** version of `contour-map-geographic`. **File:** `plots/contour-map-geographic/implementations/pygal.py` **Parent Issue:** #3772 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/21093797339)* --------- 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 3567577 commit 4df09b2

2 files changed

Lines changed: 549 additions & 0 deletions

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
""" pyplots.ai
2+
contour-map-geographic: Contour Lines on Geographic Map
3+
Library: pygal 3.1.0 | Python 3.13.11
4+
Quality: 85/100 | Created: 2026-01-17
5+
"""
6+
7+
import sys
8+
from pathlib import Path
9+
10+
11+
# Remove current directory from path to avoid shadowing pygal module
12+
sys.path = [p for p in sys.path if p != str(Path(__file__).parent)]
13+
14+
import cairosvg # noqa: E402
15+
import numpy as np # noqa: E402
16+
import pygal as pygal_lib # noqa: E402
17+
from pygal.style import Style # noqa: E402
18+
19+
20+
# Data - Generate synthetic temperature data over a geographic grid
21+
np.random.seed(42)
22+
23+
# Define geographic bounds (roughly Europe/Atlantic region)
24+
lon_min, lon_max = -30, 50
25+
lat_min, lat_max = 25, 70
26+
27+
# Create regular grid for contour data
28+
n_grid = 40
29+
lon_grid = np.linspace(lon_min, lon_max, n_grid)
30+
lat_grid = np.linspace(lat_min, lat_max, n_grid)
31+
LON, LAT = np.meshgrid(lon_grid, lat_grid)
32+
33+
# Generate synthetic temperature field (°C)
34+
temp_base = 25 - (LAT - lat_min) * 0.3
35+
temp_base += (LON - lon_min) * 0.08
36+
37+
# High pressure center (warm)
38+
high_lon, high_lat = 15, 50
39+
dist_high = np.sqrt((LON - high_lon) ** 2 + (LAT - high_lat) ** 2)
40+
temp_anomaly1 = 8 * np.exp(-(dist_high**2) / 300)
41+
42+
# Low pressure center (cold)
43+
low_lon, low_lat = -10, 60
44+
dist_low = np.sqrt((LON - low_lon) ** 2 + (LAT - low_lat) ** 2)
45+
temp_anomaly2 = -10 * np.exp(-(dist_low**2) / 250)
46+
47+
# Warm anomaly in Mediterranean
48+
med_lon, med_lat = 25, 35
49+
dist_med = np.sqrt((LON - med_lon) ** 2 + (LAT - med_lat) ** 2)
50+
temp_anomaly3 = 6 * np.exp(-(dist_med**2) / 200)
51+
52+
TEMP = temp_base + temp_anomaly1 + temp_anomaly2 + temp_anomaly3
53+
TEMP += np.random.normal(0, 0.5, TEMP.shape)
54+
55+
contour_levels = np.arange(-4, 26, 4)
56+
57+
# Simplified coastlines for Europe/Atlantic (lon, lat format)
58+
coastlines = [
59+
[(-10, 50), (-5, 55), (-7, 58), (-2, 59), (0, 61), (2, 57), (-5, 50), (-10, 50)],
60+
[(-10, 36), (-9, 43), (-2, 43), (4, 42), (-6, 36), (-10, 36)],
61+
[(-5, 48), (2, 51), (4, 51), (10, 54), (4, 56), (-2, 53), (-5, 48)],
62+
[(5, 58), (11, 58), (18, 63), (25, 70), (30, 70), (25, 62), (10, 56), (5, 58)],
63+
[(8, 44), (18, 40), (12, 38), (8, 44)],
64+
[(-10, 30), (10, 32), (30, 31), (35, 32), (35, 28), (10, 28), (-10, 30)],
65+
[(20, 35), (30, 37), (36, 35), (27, 35), (20, 35)],
66+
]
67+
68+
# Color scale for temperature (blue cold -> red warm)
69+
temp_colors = [
70+
"#2166ac", # -4: Deep blue
71+
"#4393c3", # 0: Blue
72+
"#92c5de", # 4: Light blue
73+
"#d1e5f0", # 8: Very light blue
74+
"#fddbc7", # 12: Light orange
75+
"#f4a582", # 16: Orange
76+
"#d6604d", # 20: Red-orange
77+
"#b2182b", # 24: Red
78+
]
79+
80+
# Custom style for 4800x2700 canvas
81+
custom_style = Style(
82+
background="white",
83+
plot_background="#C8DDF0",
84+
foreground="#333333",
85+
foreground_strong="#111111",
86+
foreground_subtle="#666666",
87+
guide_stroke_color="rgba(100, 100, 100, 0.25)",
88+
guide_stroke_dasharray="",
89+
colors=("#666666",) * len(coastlines),
90+
title_font_size=72,
91+
label_font_size=48,
92+
major_label_font_size=40,
93+
legend_font_size=44,
94+
value_font_size=36,
95+
)
96+
97+
# Create base XY chart
98+
chart = pygal_lib.XY(
99+
width=4800,
100+
height=2700,
101+
style=custom_style,
102+
title="contour-map-geographic · pygal · pyplots.ai",
103+
x_title="Longitude (°E)",
104+
y_title="Latitude (°N)",
105+
show_legend=False,
106+
stroke=True,
107+
dots_size=0,
108+
show_x_guides=True,
109+
show_y_guides=True,
110+
explicit_size=True,
111+
print_values=False,
112+
xrange=(lon_min, lon_max),
113+
range=(lat_min, lat_max),
114+
margin=120,
115+
margin_top=200,
116+
margin_bottom=200,
117+
margin_left=280,
118+
margin_right=300,
119+
)
120+
121+
# Add coastlines as background
122+
for coords in coastlines:
123+
chart.add(None, coords, stroke=True, dots_size=0, show_dots=False, fill=False)
124+
125+
# Plot dimensions
126+
plot_x = 280
127+
plot_y = 200
128+
plot_width = 4800 - 280 - 300
129+
plot_height = 2700 - 200 - 200
130+
131+
# Build custom SVG content for contours
132+
svg_parts = []
133+
134+
# Draw filled contours (background)
135+
cell_w = plot_width / (n_grid - 1)
136+
cell_h = plot_height / (n_grid - 1)
137+
138+
for i in range(n_grid - 1):
139+
for j in range(n_grid - 1):
140+
avg_temp = (TEMP[i, j] + TEMP[i, j + 1] + TEMP[i + 1, j] + TEMP[i + 1, j + 1]) / 4
141+
color_idx = int((avg_temp + 4) / 4)
142+
color_idx = max(0, min(color_idx, len(temp_colors) - 1))
143+
color = temp_colors[color_idx]
144+
px = plot_x + (lon_grid[j] - lon_min) / (lon_max - lon_min) * plot_width
145+
py = plot_y + plot_height - (lat_grid[i + 1] - lat_min) / (lat_max - lat_min) * plot_height
146+
svg_parts.append(
147+
f'<rect x="{px:.1f}" y="{py:.1f}" width="{cell_w + 1:.1f}" '
148+
f'height="{cell_h + 1:.1f}" fill="{color}" fill-opacity="0.6" stroke="none"/>'
149+
)
150+
151+
# Draw contour lines using marching squares algorithm with path connection
152+
for level in contour_levels:
153+
line_color = "#333333"
154+
line_width = 3 if level % 8 == 0 else 2
155+
156+
# Collect all segments for this level
157+
all_segments = []
158+
159+
for i in range(n_grid - 1):
160+
for j in range(n_grid - 1):
161+
z00, z01 = TEMP[i, j], TEMP[i, j + 1]
162+
z10, z11 = TEMP[i + 1, j], TEMP[i + 1, j + 1]
163+
164+
case = 0
165+
if z00 >= level:
166+
case |= 1
167+
if z01 >= level:
168+
case |= 2
169+
if z11 >= level:
170+
case |= 4
171+
if z10 >= level:
172+
case |= 8
173+
174+
if case == 0 or case == 15:
175+
continue
176+
177+
# Cell corner pixel coordinates
178+
x0 = plot_x + (lon_grid[j] - lon_min) / (lon_max - lon_min) * plot_width
179+
y0 = plot_y + plot_height - (lat_grid[i + 1] - lat_min) / (lat_max - lat_min) * plot_height
180+
x1 = plot_x + (lon_grid[j + 1] - lon_min) / (lon_max - lon_min) * plot_width
181+
y1 = plot_y + plot_height - (lat_grid[i] - lat_min) / (lat_max - lat_min) * plot_height
182+
183+
# Linear interpolation (inline)
184+
t_left = 0.5 if abs(z10 - z00) < 1e-10 else (level - z00) / (z10 - z00)
185+
t_right = 0.5 if abs(z11 - z01) < 1e-10 else (level - z01) / (z11 - z01)
186+
t_top = 0.5 if abs(z11 - z10) < 1e-10 else (level - z10) / (z11 - z10)
187+
t_bottom = 0.5 if abs(z01 - z00) < 1e-10 else (level - z00) / (z01 - z00)
188+
189+
left = (x0, y0 - cell_h * t_left)
190+
right = (x1, y1 + cell_h * t_right)
191+
top = (x0 + cell_w * t_top, y0 - cell_h)
192+
bottom = (x0 + cell_w * t_bottom, y0)
193+
194+
if case in [1, 14]:
195+
all_segments.append((left, bottom))
196+
elif case in [2, 13]:
197+
all_segments.append((bottom, right))
198+
elif case in [3, 12]:
199+
all_segments.append((left, right))
200+
elif case in [4, 11]:
201+
all_segments.append((right, top))
202+
elif case == 5:
203+
all_segments.append((left, top))
204+
all_segments.append((bottom, right))
205+
elif case in [6, 9]:
206+
all_segments.append((bottom, top))
207+
elif case in [7, 8]:
208+
all_segments.append((left, top))
209+
elif case == 10:
210+
all_segments.append((left, bottom))
211+
all_segments.append((right, top))
212+
213+
# Connect segments into polylines for smoother rendering
214+
tolerance = 1.5
215+
polylines = []
216+
used = [False] * len(all_segments)
217+
218+
for idx, seg in enumerate(all_segments):
219+
if used[idx]:
220+
continue
221+
used[idx] = True
222+
chain = list(seg)
223+
224+
# Try extending the chain from both ends
225+
extended = True
226+
while extended:
227+
extended = False
228+
for j, other in enumerate(all_segments):
229+
if used[j]:
230+
continue
231+
p0, p1 = other
232+
# Check if other connects to end of chain
233+
if abs(chain[-1][0] - p0[0]) < tolerance and abs(chain[-1][1] - p0[1]) < tolerance:
234+
chain.append(p1)
235+
used[j] = True
236+
extended = True
237+
elif abs(chain[-1][0] - p1[0]) < tolerance and abs(chain[-1][1] - p1[1]) < tolerance:
238+
chain.append(p0)
239+
used[j] = True
240+
extended = True
241+
# Check if other connects to start of chain
242+
elif abs(chain[0][0] - p1[0]) < tolerance and abs(chain[0][1] - p1[1]) < tolerance:
243+
chain.insert(0, p0)
244+
used[j] = True
245+
extended = True
246+
elif abs(chain[0][0] - p0[0]) < tolerance and abs(chain[0][1] - p0[1]) < tolerance:
247+
chain.insert(0, p1)
248+
used[j] = True
249+
extended = True
250+
251+
polylines.append(chain)
252+
253+
# Render polylines as SVG paths
254+
for chain in polylines:
255+
if len(chain) < 2:
256+
continue
257+
path_data = f"M {chain[0][0]:.1f} {chain[0][1]:.1f}"
258+
for pt in chain[1:]:
259+
path_data += f" L {pt[0]:.1f} {pt[1]:.1f}"
260+
svg_parts.append(
261+
f'<path d="{path_data}" fill="none" stroke="{line_color}" '
262+
f'stroke-width="{line_width}" stroke-opacity="0.85" stroke-linejoin="round" stroke-linecap="round"/>'
263+
)
264+
265+
# Add contour labels at strategic positions
266+
label_positions = [
267+
(-20, 45, -4),
268+
(-15, 58, 0),
269+
(5, 62, 4),
270+
(20, 55, 8),
271+
(30, 50, 12),
272+
(35, 42, 16),
273+
(25, 33, 20),
274+
(10, 32, 24),
275+
]
276+
277+
for lon_l, lat_l, temp_l in label_positions:
278+
if lon_min <= lon_l <= lon_max and lat_min <= lat_l <= lat_max:
279+
px = plot_x + (lon_l - lon_min) / (lon_max - lon_min) * plot_width
280+
py = plot_y + plot_height - (lat_l - lat_min) / (lat_max - lat_min) * plot_height
281+
svg_parts.append(
282+
f'<rect x="{px - 40}" y="{py - 26}" width="80" height="42" fill="white" fill-opacity="0.92" rx="5" '
283+
f'stroke="#666666" stroke-width="1"/>'
284+
)
285+
svg_parts.append(
286+
f'<text x="{px}" y="{py + 8}" text-anchor="middle" fill="#333333" '
287+
f'style="font-size:34px;font-weight:bold;font-family:sans-serif">{temp_l}°C</text>'
288+
)
289+
290+
# Add colorbar with larger, more prominent labels
291+
cb_width = 60
292+
cb_height = plot_height * 0.75
293+
cb_x = plot_x + plot_width + 70
294+
cb_y = plot_y + (plot_height - cb_height) / 2
295+
296+
n_cb_segments = len(temp_colors)
297+
seg_h = cb_height / n_cb_segments
298+
for i, color in enumerate(temp_colors[::-1]):
299+
seg_y = cb_y + i * seg_h
300+
svg_parts.append(f'<rect x="{cb_x}" y="{seg_y:.1f}" width="{cb_width}" height="{seg_h + 1:.1f}" fill="{color}"/>')
301+
302+
svg_parts.append(
303+
f'<rect x="{cb_x}" y="{cb_y}" width="{cb_width}" height="{cb_height}" '
304+
f'fill="none" stroke="#333333" stroke-width="3"/>'
305+
)
306+
307+
cb_labels = [24, 20, 16, 12, 8, 4, 0, -4]
308+
for i, val in enumerate(cb_labels):
309+
label_y = cb_y + i * seg_h + seg_h / 2 + 14
310+
svg_parts.append(
311+
f'<text x="{cb_x + cb_width + 18}" y="{label_y:.1f}" fill="#333333" '
312+
f'style="font-size:42px;font-weight:bold;font-family:sans-serif">{val}°C</text>'
313+
)
314+
315+
svg_parts.append(
316+
f'<text x="{cb_x + cb_width / 2}" y="{cb_y - 30}" text-anchor="middle" fill="#333333" '
317+
f'style="font-size:44px;font-weight:bold;font-family:sans-serif">Temperature</text>'
318+
)
319+
320+
custom_svg = "\n".join(svg_parts)
321+
322+
# Add dummy data point (required by pygal)
323+
chart.add("", [(lon_min, lat_min)])
324+
325+
# Render base chart and inject custom SVG
326+
base_svg = chart.render(is_unicode=True)
327+
output_svg = base_svg.replace("</svg>", f"{custom_svg}\n</svg>")
328+
329+
# Convert to PNG using cairosvg
330+
cairosvg.svg2png(bytestring=output_svg.encode("utf-8"), write_to="plot.png")

0 commit comments

Comments
 (0)