Skip to content

Commit e9894a3

Browse files
update(hexbin-basic): plotly — comprehensive quality review (#4315)
## Summary Updated **plotly** implementation for **hexbin-basic**. **Changes:** Comprehensive quality review ### Changes - Major code simplification (-27 lines) - Cleaner density heatmap approach - Improved interactive styling ## 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 c2d76e6 commit e9894a3

2 files changed

Lines changed: 316 additions & 141 deletions

File tree

Lines changed: 102 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,183 +1,152 @@
11
""" pyplots.ai
22
hexbin-basic: Basic Hexbin Plot
3-
Library: plotly 6.5.0 | Python 3.13.11
4-
Quality: 92/100 | Created: 2025-12-14
3+
Library: plotly 6.5.2 | Python 3.14.3
4+
Quality: 92/100 | Created: 2026-02-21
55
"""
66

77
import numpy as np
88
import plotly.graph_objects as go
99

1010

11-
# Data - generate clustered bivariate data (10,000 points)
11+
# Data - ride-share pickup density across a metro area
1212
np.random.seed(42)
1313
n_points = 10000
1414

15-
# Create clustered distribution with 3 centers
16-
centers = [(0, 0), (3, 3), (-2, 4)]
17-
points_per_cluster = n_points // 3
15+
# Three pickup hotspots with different densities and spreads
16+
# Downtown (dense hub), Airport (tight cluster), University (diffuse)
17+
clusters = [(-4, 1.0, 1.3, 4000), (1.5, 3.5, 0.9, 3500), (6, 1.5, 1.1, 2500)]
1818

19-
x_data = []
20-
y_data = []
19+
x_all, y_all = [], []
20+
for cx, cy, spread, n in clusters:
21+
x_all.extend(np.random.randn(n) * spread + cx)
22+
y_all.extend(np.random.randn(n) * spread + cy)
2123

22-
for cx, cy in centers:
23-
x_data.extend(np.random.randn(points_per_cluster) * 1.2 + cx)
24-
y_data.extend(np.random.randn(points_per_cluster) * 1.2 + cy)
24+
x = np.array(x_all)
25+
y = np.array(y_all)
2526

26-
x = np.array(x_data)
27-
y = np.array(y_data)
28-
29-
# Hexbin parameters
27+
# Hexagonal binning (plotly lacks native hexbin)
3028
gridsize = 25
3129
x_min, x_max = x.min() - 0.5, x.max() + 0.5
3230
y_min, y_max = y.min() - 0.5, y.max() + 0.5
3331

34-
# Hexagon geometry: pointy-top orientation for proper tessellation
35-
hex_size = (x_max - x_min) / gridsize / 2 # radius
36-
hex_width = hex_size * np.sqrt(3)
37-
hex_height = hex_size * 2
38-
hex_horiz_spacing = hex_width
39-
hex_vert_spacing = hex_height * 0.75
32+
hex_size = (x_max - x_min) / (gridsize * 2)
33+
hex_w = hex_size * np.sqrt(3)
34+
hex_h = hex_size * 2
35+
vert_spacing = hex_h * 0.75
4036

41-
# Compute hexagonal bin centers and counts
4237
hex_bins = {}
4338
for xi, yi in zip(x, y, strict=True):
44-
# Convert to hex grid coordinates (offset coordinates)
45-
row = int((yi - y_min) / hex_vert_spacing)
46-
col_offset = (row % 2) * hex_width * 0.5
47-
col = int((xi - x_min - col_offset) / hex_horiz_spacing)
48-
49-
# Snap to hex center
50-
hx = x_min + col * hex_horiz_spacing + col_offset + hex_width / 2
51-
hy = y_min + row * hex_vert_spacing + hex_height / 2
52-
39+
row = int((yi - y_min) / vert_spacing)
40+
offset = (row % 2) * hex_w * 0.5
41+
col = int((xi - x_min - offset) / hex_w)
42+
hx = x_min + col * hex_w + offset + hex_w / 2
43+
hy = y_min + row * vert_spacing + hex_h / 2
5344
key = (col, row)
5445
if key not in hex_bins:
55-
hex_bins[key] = {"x": hx, "y": hy, "count": 0}
56-
hex_bins[key]["count"] += 1
57-
58-
# Extract bin data
59-
hex_centers_x = [v["x"] for v in hex_bins.values()]
60-
hex_centers_y = [v["y"] for v in hex_bins.values()]
61-
counts = np.array([v["count"] for v in hex_bins.values()])
62-
63-
# Normalize counts for color mapping
64-
max_count = counts.max()
65-
normalized_counts = counts / max_count
66-
67-
# Viridis colorscale values (sampled at key points)
68-
viridis_colors = [(0.0, "#440154"), (0.25, "#3b528b"), (0.5, "#21918c"), (0.75, "#5ec962"), (1.0, "#fde725")]
69-
70-
71-
def get_viridis_color(val):
72-
"""Interpolate viridis color for value between 0 and 1."""
73-
for i in range(len(viridis_colors) - 1):
74-
v1, c1 = viridis_colors[i]
75-
v2, c2 = viridis_colors[i + 1]
76-
if v1 <= val <= v2:
77-
t = (val - v1) / (v2 - v1)
78-
r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)
79-
r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16)
80-
r = int(r1 + t * (r2 - r1))
81-
g = int(g1 + t * (g2 - g1))
82-
b = int(b1 + t * (b2 - b1))
83-
return f"rgb({r}, {g}, {b})"
84-
return viridis_colors[-1][1]
85-
86-
87-
def hexagon_vertices(cx, cy, size):
88-
"""Generate vertices for a pointy-top hexagon centered at (cx, cy)."""
89-
angles = np.array([30, 90, 150, 210, 270, 330, 390]) * np.pi / 180
90-
vx = cx + size * np.cos(angles)
91-
vy = cy + size * np.sin(angles)
92-
return vx, vy
93-
94-
95-
# Create figure
96-
fig = go.Figure()
97-
98-
# Add hexagons as shapes
99-
shapes = []
100-
for hx, hy, norm_count in zip(hex_centers_x, hex_centers_y, normalized_counts, strict=True):
101-
vx, vy = hexagon_vertices(hx, hy, hex_size * 1.02) # Slight overlap to avoid gaps
102-
color = get_viridis_color(norm_count)
103-
104-
# Build SVG path for hexagon
105-
path = f"M {vx[0]},{vy[0]}"
106-
for j in range(1, len(vx)):
107-
path += f" L {vx[j]},{vy[j]}"
108-
path += " Z"
109-
110-
shapes.append(
111-
{
112-
"type": "path",
113-
"path": path,
114-
"fillcolor": color,
115-
"line": {"width": 0.5, "color": color}, # Match line to fill to eliminate gaps
116-
}
117-
)
118-
119-
# Add invisible scatter for hover functionality
120-
fig.add_trace(
121-
go.Scatter(
122-
x=hex_centers_x,
123-
y=hex_centers_y,
124-
mode="markers",
125-
marker={"size": 1, "opacity": 0},
126-
text=[f"Count: {c}" for c in counts],
127-
hoverinfo="text",
128-
showlegend=False,
129-
)
130-
)
131-
132-
# Add a dummy scatter for colorbar
133-
fig.add_trace(
46+
hex_bins[key] = [hx, hy, 0]
47+
hex_bins[key][2] += 1
48+
49+
hex_x = np.array([v[0] for v in hex_bins.values()])
50+
hex_y = np.array([v[1] for v in hex_bins.values()])
51+
counts = np.array([v[2] for v in hex_bins.values()])
52+
53+
# Sort by count so dense hexagons render on top at overlaps
54+
order = np.argsort(counts)
55+
hex_x, hex_y, counts = hex_x[order], hex_y[order], counts[order]
56+
57+
# Marker size: slightly oversized to ensure seamless tessellation
58+
fig_w, fig_h = 1600, 900
59+
margins = {"l": 85, "r": 125, "t": 95, "b": 85}
60+
plot_w = fig_w - margins["l"] - margins["r"]
61+
plot_h = fig_h - margins["t"] - margins["b"]
62+
ax_x_range = (hex_x.max() + hex_w) - (hex_x.min() - hex_w)
63+
ax_y_range = (hex_y.max() + hex_h) - (hex_y.min() - hex_h)
64+
px_per_unit = min(plot_w / ax_x_range, plot_h / ax_y_range)
65+
marker_size = 2 * hex_size * px_per_unit * 1.78
66+
67+
# Single scatter trace with native hexagon markers, colorscale, and colorbar
68+
fig = go.Figure(
13469
go.Scatter(
135-
x=[None],
136-
y=[None],
70+
x=hex_x,
71+
y=hex_y,
13772
mode="markers",
13873
marker={
74+
"symbol": "hexagon2",
75+
"size": marker_size,
76+
"color": counts,
13977
"colorscale": "Viridis",
14078
"cmin": 0,
141-
"cmax": max_count,
79+
"cmax": int(counts.max()),
14280
"colorbar": {
143-
"title": {"text": "Count", "font": {"size": 22}},
81+
"title": {"text": "Pickups", "font": {"size": 22}},
14482
"tickfont": {"size": 18},
145-
"thickness": 25,
146-
"len": 0.8,
83+
"thickness": 22,
84+
"len": 0.7,
85+
"x": 1.01,
86+
"outlinewidth": 0,
14787
},
148-
"showscale": True,
88+
"line": {"width": 1, "color": counts, "colorscale": "Viridis", "cmin": 0, "cmax": int(counts.max())},
14989
},
150-
hoverinfo="skip",
90+
customdata=counts,
91+
hovertemplate=("East: %{x:.1f} km<br>North: %{y:.1f} km<br>Pickups: %{customdata}<extra></extra>"),
15192
showlegend=False,
15293
)
15394
)
15495

155-
# Layout
15696
fig.update_layout(
157-
title={"text": "hexbin-basic · plotly · pyplots.ai", "font": {"size": 32}, "x": 0.5, "xanchor": "center"},
97+
title={
98+
"text": "hexbin-basic · plotly · pyplots.ai",
99+
"font": {"size": 32, "color": "#2d2d2d", "family": "Arial Black, Arial"},
100+
"x": 0.5,
101+
"xanchor": "center",
102+
},
158103
xaxis={
159-
"title": {"text": "X Value", "font": {"size": 24}},
160-
"tickfont": {"size": 18},
161-
"gridcolor": "rgba(128, 128, 128, 0.2)",
162-
"gridwidth": 1,
104+
"title": {"text": "Distance East (km)", "font": {"size": 24, "color": "#555"}},
105+
"tickfont": {"size": 18, "color": "#666"},
106+
"showgrid": False,
163107
"zeroline": False,
108+
"range": [hex_x.min() - hex_w, hex_x.max() + hex_w],
164109
},
165110
yaxis={
166-
"title": {"text": "Y Value", "font": {"size": 24}},
167-
"tickfont": {"size": 18},
168-
"gridcolor": "rgba(128, 128, 128, 0.2)",
169-
"gridwidth": 1,
111+
"title": {"text": "Distance North (km)", "font": {"size": 24, "color": "#555"}},
112+
"tickfont": {"size": 18, "color": "#666"},
113+
"showgrid": False,
170114
"zeroline": False,
171115
"scaleanchor": "x",
172116
"scaleratio": 1,
117+
"range": [hex_y.min() - hex_h, hex_y.max() + hex_h],
173118
},
174119
template="plotly_white",
175-
margin={"l": 100, "r": 140, "t": 100, "b": 100},
176-
shapes=shapes,
120+
margin=margins,
121+
plot_bgcolor="#f8f9fa",
122+
hoverlabel={
123+
"bgcolor": "rgba(50,50,50,0.9)",
124+
"font": {"size": 16, "family": "Arial", "color": "white"},
125+
"bordercolor": "rgba(0,0,0,0)",
126+
},
177127
)
178128

179-
# Save as PNG (4800x2700 px)
180-
fig.write_image("plot.png", width=1600, height=900, scale=3)
129+
# Annotate cluster hotspots for data storytelling
130+
for label, cx, cy, ax, ay in [
131+
("Downtown", -4, 1.0, -45, 55),
132+
("Airport", 1.5, 3.5, 35, -50),
133+
("University", 6, 1.5, 45, 55),
134+
]:
135+
fig.add_annotation(
136+
x=cx,
137+
y=cy,
138+
text=f"<b>{label}</b>",
139+
showarrow=True,
140+
arrowhead=0,
141+
arrowwidth=1.5,
142+
arrowcolor="rgba(80,80,80,0.5)",
143+
ax=ax,
144+
ay=ay,
145+
font={"size": 16, "color": "#333", "family": "Arial"},
146+
bgcolor="rgba(255,255,255,0.85)",
147+
borderpad=4,
148+
bordercolor="rgba(0,0,0,0)",
149+
)
181150

182-
# Save interactive HTML
151+
fig.write_image("plot.png", width=fig_w, height=fig_h, scale=3)
183152
fig.write_html("plot.html", include_plotlyjs=True, full_html=True)

0 commit comments

Comments
 (0)