|
1 | 1 | """ pyplots.ai |
2 | 2 | hexbin-basic: Basic Hexbin Plot |
3 | | -Library: altair 6.0.0 | Python 3.13.11 |
4 | | -Quality: 72/100 | Created: 2025-12-23 |
| 3 | +Library: altair 6.0.0 | Python 3.14.3 |
| 4 | +Quality: 92/100 | Updated: 2026-02-21 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import altair as alt |
8 | 8 | import numpy as np |
9 | 9 | import pandas as pd |
10 | 10 |
|
11 | 11 |
|
12 | | -# Data - GPS coordinates showing traffic density in a metropolitan area |
13 | | -# Simulating vehicle GPS pings across different urban zones |
| 12 | +# Data - GPS coordinates showing traffic density in Seattle |
14 | 13 | np.random.seed(42) |
15 | 14 |
|
16 | 15 | n_points = 5000 |
17 | 16 |
|
18 | | -# Downtown core - highest density (longitude/latitude offsets from city center) |
19 | | -downtown_lon = np.random.randn(n_points // 2) * 0.008 + (-122.335) |
20 | | -downtown_lat = np.random.randn(n_points // 2) * 0.006 + 47.608 |
| 17 | +# Downtown core - highest density (tight cluster for strong density peak) |
| 18 | +downtown_lon = np.random.randn(n_points // 2) * 0.006 + (-122.335) |
| 19 | +downtown_lat = np.random.randn(n_points // 2) * 0.005 + 47.608 |
21 | 20 |
|
22 | 21 | # Shopping district - secondary hotspot |
23 | 22 | shopping_lon = np.random.randn(n_points // 3) * 0.005 + (-122.315) |
|
30 | 29 | longitude = np.concatenate([downtown_lon, shopping_lon, industrial_lon]) |
31 | 30 | latitude = np.concatenate([downtown_lat, shopping_lat, industrial_lat]) |
32 | 31 |
|
33 | | -df = pd.DataFrame({"longitude": longitude, "latitude": latitude}) |
| 32 | +# Hexagonal binning - compute hex grid positions and counts |
| 33 | +hex_radius = 0.002 |
| 34 | +dx = hex_radius * np.sqrt(3) |
| 35 | +dy = hex_radius * 1.5 |
34 | 36 |
|
35 | | -# Plot - 2D density binning using mark_rect with binning transform |
36 | | -# Altair doesn't have native hexbin, so we use rectangular binning heatmap |
37 | | -chart = ( |
38 | | - alt.Chart(df) |
39 | | - .mark_rect(stroke="white", strokeWidth=0.5) |
| 37 | +row_idx = np.round(latitude / dy).astype(int) |
| 38 | +shift = (row_idx % 2) * 0.5 |
| 39 | +col_adj = np.round((longitude / dx) - shift).astype(int) |
| 40 | + |
| 41 | +hex_cx = (col_adj + shift) * dx |
| 42 | +hex_cy = row_idx * dy |
| 43 | + |
| 44 | +hexbins = pd.DataFrame({"lon": hex_cx, "lat": hex_cy}).groupby(["lon", "lat"]).size().reset_index(name="count") |
| 45 | + |
| 46 | +# Compute pixel size for hexagons to tile cleanly |
| 47 | +chart_width, chart_height = 1600, 900 |
| 48 | +lon_range = hexbins["lon"].max() - hexbins["lon"].min() |
| 49 | +lat_range = hexbins["lat"].max() - hexbins["lat"].min() |
| 50 | +hex_px_w = dx * (chart_width / lon_range) if lon_range > 0 else 1 |
| 51 | +hex_px_h = 2 * hex_radius * (chart_height / lat_range) if lat_range > 0 else 1 |
| 52 | +hex_area = hex_px_w * hex_px_h |
| 53 | + |
| 54 | +# Custom hexagon SVG path (pointy-top) |
| 55 | +hex_path = "M0,-1L0.866,-0.5L0.866,0.5L0,1L-0.866,0.5L-0.866,-0.5Z" |
| 56 | + |
| 57 | +# Interactive hover selection — distinctive Altair/Vega-Lite feature |
| 58 | +hover = alt.selection_point(on="pointerover", nearest=True, empty=False) |
| 59 | + |
| 60 | +# Hexbin layer with hover-responsive encoding and computed density level |
| 61 | +hexbin_layer = ( |
| 62 | + alt.Chart(hexbins) |
| 63 | + .transform_calculate(density="datum.count > 60 ? 'High' : datum.count > 25 ? 'Medium' : 'Low'") |
| 64 | + .mark_point(shape=hex_path, filled=True, stroke="white") |
40 | 65 | .encode( |
41 | 66 | x=alt.X( |
42 | | - "longitude:Q", |
43 | | - bin=alt.Bin(maxbins=35), |
44 | | - title="Longitude (°W)", |
45 | | - axis=alt.Axis(labelFontSize=18, titleFontSize=22, format=".2f", grid=False), |
| 67 | + "lon:Q", |
| 68 | + title="Longitude (\u00b0W)", |
| 69 | + scale=alt.Scale(zero=False), |
| 70 | + axis=alt.Axis( |
| 71 | + labelFontSize=18, |
| 72 | + titleFontSize=22, |
| 73 | + format=".2f", |
| 74 | + values=[-122.36, -122.34, -122.32, -122.30], |
| 75 | + grid=True, |
| 76 | + gridOpacity=0.08, |
| 77 | + gridColor="#ccc", |
| 78 | + ), |
46 | 79 | ), |
47 | 80 | y=alt.Y( |
48 | | - "latitude:Q", |
49 | | - bin=alt.Bin(maxbins=25), |
50 | | - title="Latitude (°N)", |
51 | | - axis=alt.Axis(labelFontSize=18, titleFontSize=22, format=".3f", grid=False), |
| 81 | + "lat:Q", |
| 82 | + title="Latitude (\u00b0N)", |
| 83 | + scale=alt.Scale(zero=False), |
| 84 | + axis=alt.Axis( |
| 85 | + labelFontSize=18, |
| 86 | + titleFontSize=22, |
| 87 | + format=".2f", |
| 88 | + values=[47.59, 47.60, 47.61, 47.62, 47.63, 47.64], |
| 89 | + grid=True, |
| 90 | + gridOpacity=0.08, |
| 91 | + gridColor="#ccc", |
| 92 | + ), |
52 | 93 | ), |
53 | 94 | color=alt.Color( |
54 | | - "count():Q", |
55 | | - scale=alt.Scale(scheme="viridis"), |
| 95 | + "count:Q", |
| 96 | + scale=alt.Scale(scheme="viridis", type="symlog"), |
56 | 97 | legend=alt.Legend( |
57 | | - title="Vehicle Count", titleFontSize=20, labelFontSize=16, gradientLength=350, gradientThickness=25 |
| 98 | + title="Vehicle Count", |
| 99 | + titleFontSize=20, |
| 100 | + labelFontSize=16, |
| 101 | + gradientLength=350, |
| 102 | + gradientThickness=25, |
| 103 | + orient="right", |
| 104 | + offset=20, |
| 105 | + titlePadding=10, |
58 | 106 | ), |
59 | 107 | ), |
| 108 | + size=alt.value(hex_area), |
| 109 | + strokeWidth=alt.condition(hover, alt.value(2.5), alt.value(0.4)), |
60 | 110 | tooltip=[ |
61 | | - alt.Tooltip("longitude:Q", title="Longitude", bin=True), |
62 | | - alt.Tooltip("latitude:Q", title="Latitude", bin=True), |
63 | | - alt.Tooltip("count():Q", title="Vehicles"), |
| 111 | + alt.Tooltip("lon:Q", title="Longitude", format=".4f"), |
| 112 | + alt.Tooltip("lat:Q", title="Latitude", format=".4f"), |
| 113 | + alt.Tooltip("count:Q", title="Vehicles"), |
| 114 | + alt.Tooltip("density:N", title="Density Level"), |
64 | 115 | ], |
65 | 116 | ) |
| 117 | + .add_params(hover) |
| 118 | +) |
| 119 | + |
| 120 | +# Cluster annotation labels for data storytelling |
| 121 | +annotations = pd.DataFrame( |
| 122 | + { |
| 123 | + "lon": [-122.335, -122.303, -122.360], |
| 124 | + "lat": [47.587, 47.633, 47.648], |
| 125 | + "label": ["Downtown Core", "Shopping District", "Industrial Zone"], |
| 126 | + } |
| 127 | +) |
| 128 | + |
| 129 | +text_bg = ( |
| 130 | + alt.Chart(annotations) |
| 131 | + .mark_text(fontSize=16, fontWeight="bold", color="#f9f9fb", strokeWidth=4, stroke="#f9f9fb") |
| 132 | + .encode(x="lon:Q", y="lat:Q", text="label:N") |
| 133 | +) |
| 134 | + |
| 135 | +text_fg = ( |
| 136 | + alt.Chart(annotations) |
| 137 | + .mark_text(fontSize=16, fontWeight="bold", color="#2a2a2a") |
| 138 | + .encode(x="lon:Q", y="lat:Q", text="label:N") |
| 139 | +) |
| 140 | + |
| 141 | +# Compose layers with title, subtitle, and refined styling |
| 142 | +chart = ( |
| 143 | + alt.layer(hexbin_layer, text_bg, text_fg) |
66 | 144 | .properties( |
67 | | - width=1600, height=900, title=alt.Title("hexbin-basic · altair · pyplots.ai", fontSize=28, anchor="middle") |
| 145 | + width=chart_width, |
| 146 | + height=chart_height, |
| 147 | + title=alt.Title( |
| 148 | + "hexbin-basic \u00b7 altair \u00b7 pyplots.ai", |
| 149 | + fontSize=28, |
| 150 | + anchor="middle", |
| 151 | + color="#222", |
| 152 | + subtitle="Seattle metropolitan traffic density \u2014 5,000 GPS vehicle observations", |
| 153 | + subtitleFontSize=18, |
| 154 | + subtitleColor="#666", |
| 155 | + subtitlePadding=8, |
| 156 | + ), |
| 157 | + padding={"left": 20, "right": 20, "top": 10, "bottom": 10}, |
68 | 158 | ) |
69 | | - .configure_view(strokeWidth=0) |
| 159 | + .configure_view(strokeWidth=0, fill="#f9f9fb") |
| 160 | + .configure_axis(domainColor="#aaa", tickColor="#aaa", labelColor="#555", titleColor="#333") |
70 | 161 | ) |
71 | 162 |
|
72 | 163 | # Save |
|
0 commit comments