|
1 | 1 | """ pyplots.ai |
2 | 2 | hexbin-basic: Basic Hexbin Plot |
3 | | -Library: pygal 3.1.0 | Python 3.13.11 |
4 | | -Quality: 87/100 | Created: 2025-12-14 |
| 3 | +Library: pygal 3.1.0 | Python 3.14.3 |
| 4 | +Quality: 82/100 | Created: 2026-02-21 |
5 | 5 | """ |
6 | 6 |
|
7 | | -import matplotlib.pyplot as plt |
| 7 | +import math |
| 8 | +import re |
| 9 | + |
| 10 | +import cairosvg |
8 | 11 | import numpy as np |
9 | 12 | import pygal |
10 | 13 | from pygal.style import Style |
11 | 14 |
|
12 | 15 |
|
13 | | -# Data - generate bivariate data with clusters for density visualization |
| 16 | +# Data - air quality sensor network with three pollution hotspots |
14 | 17 | np.random.seed(42) |
15 | | -n_points = 5000 |
16 | 18 |
|
17 | | -# Create clustered distribution |
18 | | -cluster1_x = np.random.randn(n_points // 2) * 1.5 + 2 |
19 | | -cluster1_y = np.random.randn(n_points // 2) * 1.5 + 2 |
20 | | -cluster2_x = np.random.randn(n_points // 2) * 2 - 2 |
21 | | -cluster2_y = np.random.randn(n_points // 2) * 2 - 2 |
| 19 | +# Dense downtown core - tight, high concentration |
| 20 | +core_x = np.random.randn(2500) * 0.6 + 1.5 |
| 21 | +core_y = np.random.randn(2500) * 0.6 + 3.0 |
| 22 | + |
| 23 | +# Industrial zone - medium density, broader spread |
| 24 | +industrial_x = np.random.randn(1200) * 1.1 - 2.5 |
| 25 | +industrial_y = np.random.randn(1200) * 0.9 - 1.0 |
| 26 | + |
| 27 | +# Highway corridor - elongated, moderate density |
| 28 | +highway_x = np.random.randn(600) * 0.4 + 5.0 |
| 29 | +highway_y = np.random.randn(600) * 1.8 + 1.0 |
| 30 | + |
| 31 | +# Sparse suburban background (fewer points, tighter bounds) |
| 32 | +bg_x = np.random.uniform(-4.0, 6.5, 150) |
| 33 | +bg_y = np.random.uniform(-3.0, 5.5, 150) |
| 34 | + |
| 35 | +sensor_x = np.concatenate([core_x, industrial_x, highway_x, bg_x]) |
| 36 | +sensor_y = np.concatenate([core_y, industrial_y, highway_y, bg_y]) |
22 | 37 |
|
23 | | -x = np.concatenate([cluster1_x, cluster2_x]) |
24 | | -y = np.concatenate([cluster1_y, cluster2_y]) |
| 38 | +# Hexagonal binning: assign each point to a hex cell, then count per cell |
| 39 | +gridsize = 20 |
| 40 | +pad = 0.2 |
| 41 | +x_min, x_max = sensor_x.min() - pad, sensor_x.max() + pad |
| 42 | +y_min, y_max = sensor_y.min() - pad, sensor_y.max() + pad |
25 | 43 |
|
26 | | -# Compute hexbin using matplotlib (extract hexagon centers and counts) |
27 | | -fig_temp, ax_temp = plt.subplots() |
28 | | -hb = ax_temp.hexbin(x, y, gridsize=20, mincnt=1) |
29 | | -offsets = hb.get_offsets() |
30 | | -counts = hb.get_array() |
31 | | -plt.close(fig_temp) |
| 44 | +hex_width = (x_max - x_min) / gridsize |
| 45 | +hex_height = hex_width * 2 / np.sqrt(3) |
32 | 46 |
|
33 | | -# Normalize counts for visualization |
34 | | -count_min, count_max = counts.min(), counts.max() |
35 | | -count_range = count_max - count_min if count_max > count_min else 1 |
| 47 | +rows = ((sensor_y - y_min) / hex_height).astype(int) |
| 48 | +odd_row_shift = np.where(rows % 2 == 1, 0.5, 0.0) |
| 49 | +cols = ((sensor_x - x_min) / hex_width - odd_row_shift).astype(int) |
| 50 | + |
| 51 | +cell_ids = cols * 10000 + rows |
| 52 | +unique_cells, counts = np.unique(cell_ids, return_counts=True) |
| 53 | + |
| 54 | +cell_cols = unique_cells // 10000 |
| 55 | +cell_rows = unique_cells % 10000 |
| 56 | +cx = x_min + (cell_cols + np.where(cell_rows % 2 == 1, 0.5, 0.0)) * hex_width + hex_width / 2 |
| 57 | +cy = y_min + cell_rows * hex_height + hex_height / 2 |
| 58 | + |
| 59 | +# Refined viridis-derived palette (dark→light = sparse→dense) |
| 60 | +viridis_6 = ("#440154", "#414487", "#2a788e", "#22a884", "#7ad151", "#fde725") |
36 | 61 |
|
37 | | -# Custom style for 4800x2700 px canvas |
38 | 62 | custom_style = Style( |
39 | | - background="white", |
40 | | - plot_background="white", |
41 | | - foreground="#333333", |
42 | | - foreground_strong="#333333", |
43 | | - foreground_subtle="#666666", |
44 | | - colors=("#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"), # viridis colors |
45 | | - opacity=0.85, |
46 | | - opacity_hover=0.95, |
47 | | - title_font_size=72, |
48 | | - label_font_size=48, |
49 | | - major_label_font_size=42, |
50 | | - legend_font_size=42, |
51 | | - value_font_size=36, |
52 | | - tooltip_font_size=36, |
| 63 | + background="#ffffff", |
| 64 | + plot_background="#f7f7f2", |
| 65 | + foreground="#3a3a3a", |
| 66 | + foreground_strong="#1a1a1a", |
| 67 | + foreground_subtle="#e0e0d8", |
| 68 | + colors=viridis_6, |
| 69 | + opacity=0.92, |
| 70 | + opacity_hover=1.0, |
| 71 | + title_font_size=54, |
| 72 | + label_font_size=40, |
| 73 | + major_label_font_size=34, |
| 74 | + legend_font_size=30, |
| 75 | + value_font_size=24, |
| 76 | + tooltip_font_size=24, |
| 77 | + title_font_family="sans-serif", |
| 78 | + label_font_family="sans-serif", |
| 79 | + major_label_font_family="sans-serif", |
| 80 | + legend_font_family="sans-serif", |
| 81 | + value_font_family="sans-serif", |
53 | 82 | ) |
54 | 83 |
|
55 | | -# Create XY chart |
| 84 | +# Tight axis range: 2nd/98th percentile with minimal padding |
| 85 | +data_x_min = float(np.percentile(sensor_x, 2)) - 0.4 |
| 86 | +data_x_max = float(np.percentile(sensor_x, 98)) + 0.4 |
| 87 | +data_y_min = float(np.percentile(sensor_y, 2)) - 0.4 |
| 88 | +data_y_max = float(np.percentile(sensor_y, 98)) + 0.4 |
| 89 | + |
56 | 90 | chart = pygal.XY( |
57 | 91 | width=4800, |
58 | 92 | height=2700, |
59 | 93 | style=custom_style, |
60 | | - title="hexbin-basic · pygal · pyplots.ai", |
61 | | - x_title="X Coordinate", |
62 | | - y_title="Y Coordinate", |
| 94 | + title="hexbin-basic \u00b7 pygal \u00b7 pyplots.ai", |
| 95 | + x_title="Sensor Grid X (km)", |
| 96 | + y_title="Sensor Grid Y (km)", |
63 | 97 | show_legend=True, |
64 | 98 | legend_at_bottom=True, |
65 | | - legend_at_bottom_columns=5, |
| 99 | + legend_at_bottom_columns=6, |
| 100 | + legend_box_size=24, |
66 | 101 | stroke=False, |
67 | | - dots_size=25, |
68 | | - show_x_guides=True, |
69 | | - show_y_guides=True, |
| 102 | + dots_size=8, |
| 103 | + show_x_guides=False, |
| 104 | + show_y_guides=False, |
| 105 | + xrange=(data_x_min, data_x_max), |
| 106 | + range=(data_y_min, data_y_max), |
| 107 | + truncate_legend=-1, |
| 108 | + tooltip_border_radius=8, |
| 109 | + print_values=False, |
| 110 | + human_readable=True, |
| 111 | + x_labels_major_count=6, |
| 112 | + y_labels_major_count=6, |
| 113 | + show_minor_x_labels=False, |
| 114 | + show_minor_y_labels=False, |
| 115 | + value_formatter=lambda v: f"{v:.1f} km", |
70 | 116 | ) |
71 | 117 |
|
72 | | -# Bin hexagons by density into 5 groups (simulating colormap) |
73 | | -n_bins = 5 |
74 | | -bin_edges = np.linspace(count_min, count_max + 1, n_bins + 1) |
75 | | -bin_labels = [f"Density: {int(bin_edges[i])}-{int(bin_edges[i + 1] - 1)}" for i in range(n_bins)] |
| 118 | +# Classify hex cells into 6 density levels using log-spaced thresholds |
| 119 | +n_levels = 6 |
| 120 | +c_min, c_max = float(counts.min()), float(counts.max()) |
| 121 | +edges = np.logspace(np.log10(c_min), np.log10(c_max + 1), n_levels + 1) |
| 122 | +edges[0] = c_min |
| 123 | +edges[-1] = c_max + 1 |
76 | 124 |
|
77 | | -# Create series for each density level |
78 | | -series_data = [[] for _ in range(n_bins)] |
79 | | -for offset, count in zip(offsets, counts, strict=True): |
80 | | - bin_idx = min(int((count - count_min) / count_range * (n_bins - 1)), n_bins - 1) |
81 | | - series_data[bin_idx].append((float(offset[0]), float(offset[1]))) |
| 125 | +level_names = ["Sparse", "Low", "Medium", "Moderate", "Dense", "Hotspot"] |
| 126 | +labels = [f"{level_names[i]} ({int(edges[i])}\u2013{int(edges[i + 1])})" for i in range(n_levels)] |
82 | 127 |
|
83 | | -# Add series with increasing dot sizes for density |
84 | | -dot_sizes = [15, 22, 28, 34, 40] |
85 | | -for i in range(n_bins): |
| 128 | +series_data = [[] for _ in range(n_levels)] |
| 129 | +for x, y, cnt in zip(cx, cy, counts, strict=True): |
| 130 | + level = min(int(np.searchsorted(edges[1:], cnt)), n_levels - 1) |
| 131 | + series_data[level].append({"value": (round(float(x), 2), round(float(y), 2)), "label": f"{int(cnt)} readings"}) |
| 132 | + |
| 133 | +# Dot sizes: wider range for stronger visual hierarchy |
| 134 | +dot_sizes = [8, 14, 22, 34, 48, 62] |
| 135 | +for i in range(n_levels): |
86 | 136 | if series_data[i]: |
87 | | - chart.add(bin_labels[i], series_data[i], dots_size=dot_sizes[i]) |
88 | | - |
89 | | -# Save outputs |
90 | | -chart.render_to_file("plot.svg") |
91 | | -chart.render_to_png("plot.png") |
92 | | - |
93 | | -# Also save HTML for interactivity |
94 | | -html_content = f"""<!DOCTYPE html> |
95 | | -<html> |
96 | | -<head> |
97 | | - <meta charset="utf-8"> |
98 | | - <title>hexbin-basic - pygal</title> |
99 | | - <style> |
100 | | - body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f5f5f5; }} |
101 | | - .chart {{ max-width: 100%; height: auto; }} |
102 | | - </style> |
103 | | -</head> |
104 | | -<body> |
105 | | - <figure class="chart"> |
106 | | - {chart.render(is_unicode=True)} |
107 | | - </figure> |
108 | | -</body> |
109 | | -</html> |
110 | | -""" |
| 137 | + chart.add(labels[i], series_data[i], dots_size=dot_sizes[i]) |
| 138 | + |
| 139 | +# Render SVG, transform circle dots → hexagonal polygon markers, save PNG |
| 140 | +svg_raw = chart.render() |
| 141 | +svg_text = svg_raw.decode("utf-8") if isinstance(svg_raw, bytes) else svg_raw |
| 142 | + |
| 143 | + |
| 144 | +def _circle_to_hex(match): |
| 145 | + tag = match.group(0) |
| 146 | + cx_m = re.search(r'cx="([\d.e+-]+)"', tag) |
| 147 | + cy_m = re.search(r'cy="([\d.e+-]+)"', tag) |
| 148 | + r_m = re.search(r'\br="([\d.e+-]+)"', tag) |
| 149 | + if not (cx_m and cy_m and r_m): |
| 150 | + return tag |
| 151 | + r_v = float(r_m.group(1)) |
| 152 | + if r_v < 1.0: |
| 153 | + return tag |
| 154 | + xc, yc = float(cx_m.group(1)), float(cy_m.group(1)) |
| 155 | + pts = " ".join( |
| 156 | + f"{xc + r_v * math.cos(math.radians(a)):.2f},{yc + r_v * math.sin(math.radians(a)):.2f}" |
| 157 | + for a in range(0, 360, 60) |
| 158 | + ) |
| 159 | + result = re.sub(r'\bcx="[\d.e+-]+"', "", tag) |
| 160 | + result = re.sub(r'\bcy="[\d.e+-]+"', "", result) |
| 161 | + result = re.sub(r'\br="[\d.e+-]+"', f'points="{pts}"', result, count=1) |
| 162 | + return result.replace("<circle", "<polygon") |
| 163 | + |
| 164 | + |
| 165 | +svg_hex = re.sub(r"<circle[^>]*/>", _circle_to_hex, svg_text) |
| 166 | + |
| 167 | +with open("plot.svg", "w", encoding="utf-8") as f: |
| 168 | + f.write(svg_hex) |
111 | 169 |
|
112 | | -with open("plot.html", "w", encoding="utf-8") as f: |
113 | | - f.write(html_content) |
| 170 | +cairosvg.svg2png(bytestring=svg_hex.encode("utf-8"), write_to="plot.png") |
0 commit comments