Skip to content

Commit ed169b0

Browse files
feat(pygal): implement contour-density (#3155)
## Implementation: `contour-density` - pygal Implements the **pygal** version of `contour-density`. **File:** `plots/contour-density/implementations/pygal.py` **Parent Issue:** #2552 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20645830405)* --------- 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 0e80dd2 commit ed169b0

2 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
""" pyplots.ai
2+
contour-density: Density Contour Plot
3+
Library: pygal 3.1.0 | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-01
5+
"""
6+
7+
import sys
8+
from pathlib import Path
9+
10+
11+
# Remove script directory from path to avoid name collision with pygal package
12+
_script_dir = str(Path(__file__).parent)
13+
sys.path = [p for p in sys.path if p != _script_dir]
14+
15+
import cairosvg # noqa: E402
16+
import numpy as np # noqa: E402
17+
import pygal # noqa: E402
18+
from pygal.style import Style # noqa: E402
19+
from scipy import stats # noqa: E402
20+
21+
22+
# Data: Bivariate distribution with multiple clusters (temperature vs humidity sensors)
23+
np.random.seed(42)
24+
25+
# Create realistic clustered data - weather sensor readings
26+
n_samples = 500
27+
28+
# Cluster 1: Morning readings (cool, humid)
29+
n1 = 180
30+
x1 = np.random.normal(15, 2.5, n1) # Temperature (°C)
31+
y1 = np.random.normal(75, 8, n1) # Humidity (%)
32+
33+
# Cluster 2: Midday readings (warm, moderate humidity)
34+
n2 = 200
35+
x2 = np.random.normal(28, 3, n2)
36+
y2 = np.random.normal(45, 10, n2)
37+
38+
# Cluster 3: Evening readings (moderate temp, varied humidity)
39+
n3 = 120
40+
x3 = np.random.normal(22, 4, n3)
41+
y3 = np.random.normal(60, 12, n3)
42+
43+
# Combine all data
44+
x_data = np.concatenate([x1, x2, x3])
45+
y_data = np.concatenate([y1, y2, y3])
46+
47+
# Compute 2D KDE (Kernel Density Estimation)
48+
n_grid = 100
49+
x_min, x_max = x_data.min() - 2, x_data.max() + 2
50+
y_min, y_max = y_data.min() - 5, y_data.max() + 5
51+
52+
x_grid = np.linspace(x_min, x_max, n_grid)
53+
y_grid = np.linspace(y_min, y_max, n_grid)
54+
X, Y = np.meshgrid(x_grid, y_grid)
55+
positions = np.vstack([X.ravel(), Y.ravel()])
56+
57+
# Fit KDE
58+
values = np.vstack([x_data, y_data])
59+
kernel = stats.gaussian_kde(values)
60+
Z = np.reshape(kernel(positions).T, X.shape)
61+
62+
z_min, z_max = Z.min(), Z.max()
63+
64+
# Sequential colormap for density (light to dark blue with Python colors)
65+
colormap = [
66+
"#f7fbff",
67+
"#deebf7",
68+
"#c6dbef",
69+
"#9ecae1",
70+
"#6baed6",
71+
"#4292c6",
72+
"#306998", # Python Blue
73+
"#214e6b",
74+
"#08306b",
75+
]
76+
77+
78+
def interpolate_color(value, vmin, vmax):
79+
"""Get color for value using linear interpolation."""
80+
if vmax == vmin:
81+
return colormap[len(colormap) // 2]
82+
norm = max(0, min(1, (value - vmin) / (vmax - vmin)))
83+
pos = norm * (len(colormap) - 1)
84+
i1, i2 = int(pos), min(int(pos) + 1, len(colormap) - 1)
85+
frac = pos - i1
86+
c1, c2 = colormap[i1], colormap[i2]
87+
r = int(int(c1[1:3], 16) + (int(c2[1:3], 16) - int(c1[1:3], 16)) * frac)
88+
g = int(int(c1[3:5], 16) + (int(c2[3:5], 16) - int(c1[3:5], 16)) * frac)
89+
b = int(int(c1[5:7], 16) + (int(c2[5:7], 16) - int(c1[5:7], 16)) * frac)
90+
return f"#{r:02x}{g:02x}{b:02x}"
91+
92+
93+
# Style for 4800x2700 canvas
94+
custom_style = Style(
95+
background="white",
96+
plot_background="white",
97+
foreground="#333333",
98+
foreground_strong="#333333",
99+
foreground_subtle="#666666",
100+
colors=("#306998",),
101+
title_font_size=72,
102+
legend_font_size=48,
103+
label_font_size=42,
104+
value_font_size=36,
105+
font_family="sans-serif",
106+
)
107+
108+
# Create base XY chart
109+
chart = pygal.XY(
110+
width=4800,
111+
height=2700,
112+
style=custom_style,
113+
title="contour-density · pygal · pyplots.ai",
114+
show_legend=False,
115+
margin=120,
116+
margin_top=200,
117+
margin_bottom=200,
118+
margin_left=300,
119+
margin_right=350,
120+
show_x_labels=False,
121+
show_y_labels=False,
122+
show_x_guides=False,
123+
show_y_guides=False,
124+
x_title="",
125+
y_title="",
126+
)
127+
128+
# Plot dimensions (matching chart margins)
129+
plot_x = 300
130+
plot_y = 200
131+
plot_width = 4800 - 300 - 350
132+
plot_height = 2700 - 200 - 200
133+
134+
# Cell size
135+
cell_w = plot_width / (n_grid - 1)
136+
cell_h = plot_height / (n_grid - 1)
137+
138+
# Build SVG content
139+
svg_parts = []
140+
141+
# Draw filled density cells
142+
for i in range(n_grid - 1):
143+
for j in range(n_grid - 1):
144+
# Average of 4 corners for cell color
145+
cell_val = (Z[i, j] + Z[i, j + 1] + Z[i + 1, j] + Z[i + 1, j + 1]) / 4
146+
color = interpolate_color(cell_val, z_min, z_max)
147+
cx = plot_x + j * cell_w
148+
cy = plot_y + plot_height - (i + 1) * cell_h
149+
svg_parts.append(
150+
f'<rect x="{cx:.1f}" y="{cy:.1f}" width="{cell_w + 0.5:.1f}" '
151+
f'height="{cell_h + 0.5:.1f}" fill="{color}" stroke="none"/>'
152+
)
153+
154+
# Draw contour lines using marching squares
155+
n_contour_levels = 10
156+
contour_levels = np.linspace(z_min + (z_max - z_min) * 0.1, z_max * 0.95, n_contour_levels)
157+
158+
for level in contour_levels:
159+
for i in range(n_grid - 1):
160+
for j in range(n_grid - 1):
161+
z00, z01 = Z[i, j], Z[i, j + 1]
162+
z10, z11 = Z[i + 1, j], Z[i + 1, j + 1]
163+
164+
# Marching squares case
165+
case = 0
166+
if z00 >= level:
167+
case |= 1
168+
if z01 >= level:
169+
case |= 2
170+
if z11 >= level:
171+
case |= 4
172+
if z10 >= level:
173+
case |= 8
174+
175+
if case == 0 or case == 15:
176+
continue
177+
178+
# Cell position
179+
x0 = plot_x + j * cell_w
180+
y0 = plot_y + plot_height - (i + 1) * cell_h
181+
182+
# Linear interpolation helper
183+
def lerp(v1, v2, lv):
184+
if abs(v2 - v1) < 1e-10:
185+
return 0.5
186+
return (lv - v1) / (v2 - v1)
187+
188+
# Edge midpoints
189+
left = (x0, y0 + cell_h * lerp(z00, z10, level))
190+
right = (x0 + cell_w, y0 + cell_h * lerp(z01, z11, level))
191+
top = (x0 + cell_w * lerp(z10, z11, level), y0 + cell_h)
192+
bottom = (x0 + cell_w * lerp(z00, z01, level), y0)
193+
194+
segments = []
195+
if case in [1, 14]:
196+
segments.append((left, bottom))
197+
elif case in [2, 13]:
198+
segments.append((bottom, right))
199+
elif case in [3, 12]:
200+
segments.append((left, right))
201+
elif case in [4, 11]:
202+
segments.append((right, top))
203+
elif case == 5:
204+
segments.append((left, top))
205+
segments.append((bottom, right))
206+
elif case in [6, 9]:
207+
segments.append((bottom, top))
208+
elif case in [7, 8]:
209+
segments.append((left, top))
210+
elif case == 10:
211+
segments.append((left, bottom))
212+
segments.append((right, top))
213+
214+
for (x1, y1), (x2, y2) in segments:
215+
svg_parts.append(
216+
f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
217+
f'stroke="#333333" stroke-width="2" stroke-opacity="0.5"/>'
218+
)
219+
220+
# Optional: Add scatter points overlay (semi-transparent) for context
221+
for px, py in zip(x_data[::5], y_data[::5], strict=True): # Sample every 5th point
222+
sx = plot_x + (px - x_min) / (x_max - x_min) * plot_width
223+
sy = plot_y + plot_height - (py - y_min) / (y_max - y_min) * plot_height
224+
svg_parts.append(
225+
f'<circle cx="{sx:.1f}" cy="{sy:.1f}" r="4" fill="#FFD43B" stroke="#333" stroke-width="0.5" opacity="0.6"/>'
226+
)
227+
228+
# Axis frame
229+
svg_parts.append(
230+
f'<rect x="{plot_x}" y="{plot_y}" width="{plot_width}" height="{plot_height}" '
231+
f'fill="none" stroke="#333333" stroke-width="2"/>'
232+
)
233+
234+
# X-axis labels and ticks
235+
n_x_ticks = 7
236+
for i in range(n_x_ticks):
237+
frac = i / (n_x_ticks - 1)
238+
tick_x = plot_x + frac * plot_width
239+
tick_y = plot_y + plot_height
240+
val = x_min + frac * (x_max - x_min)
241+
svg_parts.append(
242+
f'<line x1="{tick_x:.1f}" y1="{tick_y}" x2="{tick_x:.1f}" y2="{tick_y + 15}" stroke="#333333" stroke-width="2"/>'
243+
)
244+
svg_parts.append(
245+
f'<text x="{tick_x:.1f}" y="{tick_y + 55}" text-anchor="middle" fill="#333333" '
246+
f'style="font-size:36px;font-family:sans-serif">{val:.0f}</text>'
247+
)
248+
249+
# X-axis title
250+
svg_parts.append(
251+
f'<text x="{plot_x + plot_width / 2}" y="{plot_y + plot_height + 130}" text-anchor="middle" '
252+
f'fill="#333333" style="font-size:44px;font-weight:bold;font-family:sans-serif">Temperature (°C)</text>'
253+
)
254+
255+
# Y-axis labels and ticks
256+
n_y_ticks = 7
257+
for i in range(n_y_ticks):
258+
frac = i / (n_y_ticks - 1)
259+
tick_y = plot_y + plot_height - frac * plot_height
260+
tick_x = plot_x
261+
val = y_min + frac * (y_max - y_min)
262+
svg_parts.append(
263+
f'<line x1="{tick_x - 15}" y1="{tick_y:.1f}" x2="{tick_x}" y2="{tick_y:.1f}" stroke="#333333" stroke-width="2"/>'
264+
)
265+
svg_parts.append(
266+
f'<text x="{tick_x - 25}" y="{tick_y + 12:.1f}" text-anchor="end" fill="#333333" '
267+
f'style="font-size:36px;font-family:sans-serif">{val:.0f}</text>'
268+
)
269+
270+
# Y-axis title (rotated)
271+
y_title_x = plot_x - 180
272+
y_title_y = plot_y + plot_height / 2
273+
svg_parts.append(
274+
f'<text x="{y_title_x}" y="{y_title_y}" text-anchor="middle" fill="#333333" '
275+
f'style="font-size:44px;font-weight:bold;font-family:sans-serif" '
276+
f'transform="rotate(-90, {y_title_x}, {y_title_y})">Humidity (%)</text>'
277+
)
278+
279+
# Colorbar
280+
cb_width = 50
281+
cb_height = plot_height * 0.85
282+
cb_x = plot_x + plot_width + 60
283+
cb_y = plot_y + (plot_height - cb_height) / 2
284+
285+
# Colorbar gradient
286+
n_cb_segments = 80
287+
seg_h = cb_height / n_cb_segments
288+
for i in range(n_cb_segments):
289+
seg_val = z_max - (z_max - z_min) * i / (n_cb_segments - 1)
290+
seg_color = interpolate_color(seg_val, z_min, z_max)
291+
seg_y = cb_y + i * seg_h
292+
svg_parts.append(
293+
f'<rect x="{cb_x}" y="{seg_y:.1f}" width="{cb_width}" height="{seg_h + 1:.1f}" fill="{seg_color}"/>'
294+
)
295+
296+
# Colorbar border
297+
svg_parts.append(
298+
f'<rect x="{cb_x}" y="{cb_y}" width="{cb_width}" height="{cb_height}" fill="none" stroke="#333333" stroke-width="2"/>'
299+
)
300+
301+
# Colorbar labels (density values)
302+
n_cb_labels = 5
303+
for i in range(n_cb_labels):
304+
frac = i / (n_cb_labels - 1)
305+
val = z_max - (z_max - z_min) * frac
306+
label_y = cb_y + frac * cb_height + 12
307+
# Format as scientific notation for small density values
308+
svg_parts.append(
309+
f'<text x="{cb_x + cb_width + 15}" y="{label_y:.1f}" fill="#333333" '
310+
f'style="font-size:32px;font-family:sans-serif">{val:.4f}</text>'
311+
)
312+
313+
# Colorbar title
314+
cb_title_x = cb_x + cb_width / 2
315+
cb_title_y = cb_y - 30
316+
svg_parts.append(
317+
f'<text x="{cb_title_x}" y="{cb_title_y}" text-anchor="middle" fill="#333333" '
318+
f'style="font-size:38px;font-weight:bold;font-family:sans-serif">Density</text>'
319+
)
320+
321+
# Combine all SVG parts
322+
custom_svg = "\n".join(svg_parts)
323+
324+
# Add dummy data point (required by pygal)
325+
chart.add("", [(0, 0)])
326+
327+
# Render base chart and inject custom SVG
328+
base_svg = chart.render(is_unicode=True)
329+
330+
# Insert custom contour SVG before the closing </svg> tag
331+
output_svg = base_svg.replace("</svg>", f"{custom_svg}\n</svg>")
332+
333+
# Save SVG
334+
with open("plot.svg", "w", encoding="utf-8") as f:
335+
f.write(output_svg)
336+
337+
# Convert to PNG using cairosvg
338+
cairosvg.svg2png(bytestring=output_svg.encode("utf-8"), write_to="plot.png")
339+
340+
# Save interactive HTML
341+
html_content = f"""<!DOCTYPE html>
342+
<html>
343+
<head>
344+
<meta charset="utf-8">
345+
<title>contour-density - pygal</title>
346+
<style>
347+
body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f5f5f5; }}
348+
.chart {{ max-width: 100%; height: auto; }}
349+
</style>
350+
</head>
351+
<body>
352+
<figure class="chart">
353+
{output_svg}
354+
</figure>
355+
</body>
356+
</html>
357+
"""
358+
359+
with open("plot.html", "w", encoding="utf-8") as f:
360+
f.write(html_content)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
library: pygal
2+
specification_id: contour-density
3+
created: '2026-01-01T21:29:18Z'
4+
updated: '2026-01-01T21:31:46Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20645830405
7+
issue: 2552
8+
python_version: 3.13.11
9+
library_version: 3.1.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/contour-density/pygal/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/contour-density/pygal/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/contour-density/pygal/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- 'Excellent creative solution: implements KDE-based contour density using custom
17+
SVG injection into pygal, working around pygal''s lack of native contour support'
18+
- High-quality visual output with smooth density gradient and clear contour lines
19+
using marching squares algorithm
20+
- Realistic weather sensor data scenario with three distinct clusters showing morning,
21+
midday, and evening readings
22+
- Proper colorbar with density scale values in scientific notation
23+
- Scatter point overlay provides context as suggested in the specification
24+
- Correct title format and well-labeled axes with units
25+
weaknesses:
26+
- Grid lines are not displayed, which could help with reading values
27+
- Scatter overlay points lack a legend entry explaining what they represent
28+
- Helper function used for color interpolation deviates slightly from pure KISS
29+
structure

0 commit comments

Comments
 (0)