Skip to content

Commit c2d76e6

Browse files
update(hexbin-basic): pygal — comprehensive quality review (#4319)
## Summary Updated **pygal** implementation for **hexbin-basic**. **Changes:** Comprehensive quality review ### Changes - Stronger visual differentiation (v2 revision) - Better color gradient for density - Improved hexagon sizing and patterns ## Test Plan - [x] Preview images uploaded to GCS staging - [x] Implementation file passes ruff format/check - [x] Metadata YAML updated with current versions - [ ] Automated review triggered --- Generated with [Claude Code](https://claude.com/claude-code) `/update` command --------- 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 9d7fee4 commit c2d76e6

2 files changed

Lines changed: 361 additions & 91 deletions

File tree

Lines changed: 139 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,170 @@
11
""" pyplots.ai
22
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
55
"""
66

7-
import matplotlib.pyplot as plt
7+
import math
8+
import re
9+
10+
import cairosvg
811
import numpy as np
912
import pygal
1013
from pygal.style import Style
1114

1215

13-
# Data - generate bivariate data with clusters for density visualization
16+
# Data - air quality sensor network with three pollution hotspots
1417
np.random.seed(42)
15-
n_points = 5000
1618

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])
2237

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
2543

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)
3246

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")
3661

37-
# Custom style for 4800x2700 px canvas
3862
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",
5382
)
5483

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+
5690
chart = pygal.XY(
5791
width=4800,
5892
height=2700,
5993
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)",
6397
show_legend=True,
6498
legend_at_bottom=True,
65-
legend_at_bottom_columns=5,
99+
legend_at_bottom_columns=6,
100+
legend_box_size=24,
66101
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",
70116
)
71117

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
76124

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)]
82127

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):
86136
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)
111169

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

Comments
 (0)