|
| 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) |
0 commit comments