|
1 | 1 | """ pyplots.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
8 | 8 | import plotly.graph_objects as go |
9 | 9 |
|
10 | 10 |
|
11 | | -# Data - generate clustered bivariate data (10,000 points) |
| 11 | +# Data - ride-share pickup density across a metro area |
12 | 12 | np.random.seed(42) |
13 | 13 | n_points = 10000 |
14 | 14 |
|
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)] |
18 | 18 |
|
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) |
21 | 23 |
|
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) |
25 | 26 |
|
26 | | -x = np.array(x_data) |
27 | | -y = np.array(y_data) |
28 | | - |
29 | | -# Hexbin parameters |
| 27 | +# Hexagonal binning (plotly lacks native hexbin) |
30 | 28 | gridsize = 25 |
31 | 29 | x_min, x_max = x.min() - 0.5, x.max() + 0.5 |
32 | 30 | y_min, y_max = y.min() - 0.5, y.max() + 0.5 |
33 | 31 |
|
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 |
40 | 36 |
|
41 | | -# Compute hexagonal bin centers and counts |
42 | 37 | hex_bins = {} |
43 | 38 | 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 |
53 | 44 | key = (col, row) |
54 | 45 | 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( |
134 | 69 | go.Scatter( |
135 | | - x=[None], |
136 | | - y=[None], |
| 70 | + x=hex_x, |
| 71 | + y=hex_y, |
137 | 72 | mode="markers", |
138 | 73 | marker={ |
| 74 | + "symbol": "hexagon2", |
| 75 | + "size": marker_size, |
| 76 | + "color": counts, |
139 | 77 | "colorscale": "Viridis", |
140 | 78 | "cmin": 0, |
141 | | - "cmax": max_count, |
| 79 | + "cmax": int(counts.max()), |
142 | 80 | "colorbar": { |
143 | | - "title": {"text": "Count", "font": {"size": 22}}, |
| 81 | + "title": {"text": "Pickups", "font": {"size": 22}}, |
144 | 82 | "tickfont": {"size": 18}, |
145 | | - "thickness": 25, |
146 | | - "len": 0.8, |
| 83 | + "thickness": 22, |
| 84 | + "len": 0.7, |
| 85 | + "x": 1.01, |
| 86 | + "outlinewidth": 0, |
147 | 87 | }, |
148 | | - "showscale": True, |
| 88 | + "line": {"width": 1, "color": counts, "colorscale": "Viridis", "cmin": 0, "cmax": int(counts.max())}, |
149 | 89 | }, |
150 | | - hoverinfo="skip", |
| 90 | + customdata=counts, |
| 91 | + hovertemplate=("East: %{x:.1f} km<br>North: %{y:.1f} km<br>Pickups: %{customdata}<extra></extra>"), |
151 | 92 | showlegend=False, |
152 | 93 | ) |
153 | 94 | ) |
154 | 95 |
|
155 | | -# Layout |
156 | 96 | 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 | + }, |
158 | 103 | 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, |
163 | 107 | "zeroline": False, |
| 108 | + "range": [hex_x.min() - hex_w, hex_x.max() + hex_w], |
164 | 109 | }, |
165 | 110 | 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, |
170 | 114 | "zeroline": False, |
171 | 115 | "scaleanchor": "x", |
172 | 116 | "scaleratio": 1, |
| 117 | + "range": [hex_y.min() - hex_h, hex_y.max() + hex_h], |
173 | 118 | }, |
174 | 119 | 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 | + }, |
177 | 127 | ) |
178 | 128 |
|
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 | + ) |
181 | 150 |
|
182 | | -# Save interactive HTML |
| 151 | +fig.write_image("plot.png", width=fig_w, height=fig_h, scale=3) |
183 | 152 | fig.write_html("plot.html", include_plotlyjs=True, full_html=True) |
0 commit comments