Skip to content

Commit 158ffd1

Browse files
feat(altair): implement hexbin-map-geographic (#7752)
## Implementation: `hexbin-map-geographic` - python/altair Implements the **python/altair** version of `hexbin-map-geographic`. **File:** `plots/hexbin-map-geographic/implementations/python/altair.py` **Parent Issue:** #3767 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26513518255)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent ee34e3b commit 158ffd1

2 files changed

Lines changed: 413 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
""" anyplot.ai
2+
hexbin-map-geographic: Hexagonal Binning Map
3+
Library: altair 6.1.0 | Python 3.13.13
4+
Quality: 91/100 | Created: 2026-05-27
5+
"""
6+
7+
import os
8+
import sys
9+
from collections import Counter
10+
11+
12+
# Work around filename shadowing the altair library
13+
sys.path.pop(0)
14+
15+
import altair as alt
16+
import numpy as np
17+
from PIL import Image
18+
19+
20+
# Theme tokens
21+
THEME = os.getenv("ANYPLOT_THEME", "light")
22+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
23+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
24+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
25+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
26+
LAND_FILL = "#E8E4DC" if THEME == "light" else "#2C2C28"
27+
LAND_STROKE = "#B0AFA8" if THEME == "light" else "#4A4A44"
28+
29+
# Data: bird species sightings — deliberately wide density spread to showcase log-scale range
30+
np.random.seed(42)
31+
# (lat, lon, n_obs, spread_deg) — from sparse (150) to heavy (2500)
32+
clusters = [
33+
(47.6, -122.3, 2000, 0.45), # Seattle / Pacific flyway (dense hub)
34+
(37.8, -122.4, 800, 0.40), # San Francisco Bay (moderate)
35+
(29.8, -95.4, 350, 0.40), # Houston / Gulf Coast (lighter)
36+
(41.9, -87.6, 1500, 0.45), # Chicago / Great Lakes (dense hub)
37+
(40.7, -74.0, 2500, 0.40), # New York metro (densest hub)
38+
(38.9, -77.0, 500, 0.38), # Washington DC (moderate)
39+
(25.8, -80.2, 150, 0.38), # Miami / Atlantic flyway (sparse)
40+
(44.9, -93.2, 250, 0.42), # Minneapolis / Mississippi corridor (sparse)
41+
]
42+
43+
lats_all, lons_all = [], []
44+
for clat, clon, n, spread in clusters:
45+
lats_all.extend(np.random.normal(clat, spread, n))
46+
lons_all.extend(np.random.normal(clon, spread, n))
47+
48+
lats = np.clip(np.array(lats_all), 24.0, 50.0)
49+
lons = np.clip(np.array(lons_all), -126.0, -65.0)
50+
51+
# Flat-top hexagonal binning in lat/lon space
52+
HEX_R = 0.65 # degrees (~65 km at mid-latitudes)
53+
COL_STEP = 1.5 * HEX_R
54+
ROW_STEP = HEX_R * np.sqrt(3)
55+
56+
cols = np.round(lons / COL_STEP).astype(int)
57+
row_off = (np.abs(cols) % 2) * 0.5
58+
rows = np.round(lats / ROW_STEP - row_off).astype(int)
59+
hex_counts = Counter(zip(cols.tolist(), rows.tolist(), strict=False))
60+
61+
features = []
62+
for (c, r), count in hex_counts.items():
63+
lon_c = c * COL_STEP
64+
lat_c = (r + (abs(c) % 2) * 0.5) * ROW_STEP
65+
if not (23.5 <= lat_c <= 51.0 and -127.0 <= lon_c <= -64.0):
66+
continue
67+
# Clockwise winding in geographic coords → renders as filled interior
68+
# (vl-convert flips y-axis; CW geo = CCW screen = D3 interior fill)
69+
verts = [
70+
[lon_c + HEX_R * np.cos(np.radians(-60 * i)), lat_c + HEX_R * np.sin(np.radians(-60 * i))] for i in range(6)
71+
]
72+
verts.append(verts[0])
73+
features.append(
74+
{
75+
"type": "Feature",
76+
"geometry": {"type": "Polygon", "coordinates": [verts]},
77+
"properties": {"count": int(count)},
78+
}
79+
)
80+
81+
# Labels for the three strongest migration hubs — guide viewer to the story
82+
ann_data = [
83+
{"lon": -74.0, "lat": 41.2, "label": "NYC Metro"},
84+
{"lon": -122.3, "lat": 47.0, "label": "Seattle"},
85+
{"lon": -87.6, "lat": 42.5, "label": "Chicago"},
86+
]
87+
88+
# Base map — vega CDN, no local package required
89+
WORLD_URL = "https://cdn.jsdelivr.net/npm/vega-datasets@v1.29.0/data/world-110m.json"
90+
# Mercator projection centred on continental US, tuned to fit inner 620×320 view
91+
proj_params = {"type": "mercator", "scale": 680, "center": [-96, 37]}
92+
93+
basemap = (
94+
alt.Chart(alt.topo_feature(WORLD_URL, "land"))
95+
.mark_geoshape(fill=LAND_FILL, stroke=LAND_STROKE, strokeWidth=0.5)
96+
.project(**proj_params)
97+
)
98+
99+
# Hexbin layer — inline GeoJSON features, sequential colormap brand-green → blue
100+
hexbin = (
101+
alt.Chart(alt.InlineData(values=features, format=alt.DataFormat(type="json")))
102+
.mark_geoshape(stroke=PAGE_BG, strokeWidth=0.3, opacity=0.85)
103+
.encode(
104+
color=alt.Color(
105+
"properties.count:Q",
106+
scale=alt.Scale(type="log", range=["#009E73", "#4467A3"]),
107+
legend=alt.Legend(
108+
title="Sightings", gradientLength=140, gradientThickness=14, labelFontSize=10, titleFontSize=10
109+
),
110+
),
111+
tooltip=[alt.Tooltip("properties.count:Q", title="Sightings")],
112+
)
113+
.project(**proj_params)
114+
)
115+
116+
# Text annotations directing the viewer to the densest migration hubs
117+
annotation = (
118+
alt.Chart(alt.InlineData(values=ann_data))
119+
.mark_text(color=INK, fontSize=8, fontWeight="bold", align="center", dy=-4)
120+
.encode(longitude="lon:Q", latitude="lat:Q", text="label:N")
121+
.project(**proj_params)
122+
)
123+
124+
chart = (
125+
alt.layer(basemap, hexbin, annotation)
126+
.properties(
127+
width=620,
128+
height=320,
129+
background=PAGE_BG,
130+
title=alt.TitleParams(
131+
text="US Bird Migration Hotspots", subtitle="hexbin-map-geographic · python · altair · anyplot.ai"
132+
),
133+
padding={"left": 0, "right": 0, "top": 0, "bottom": 0},
134+
)
135+
.configure_title(color=INK, fontSize=18, fontWeight="bold", subtitleColor=INK_SOFT, subtitleFontSize=10)
136+
.configure_view(fill=PAGE_BG, stroke=None)
137+
.configure_legend(
138+
fillColor=ELEVATED_BG,
139+
strokeColor=INK_SOFT,
140+
labelColor=INK_SOFT,
141+
titleColor=INK,
142+
labelFontSize=10,
143+
titleFontSize=10,
144+
)
145+
)
146+
147+
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
148+
149+
TW, TH = 3200, 1800
150+
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
151+
_w, _h = _img.size
152+
if _w > TW or _h > TH:
153+
raise SystemExit(
154+
f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
155+
f"Shrink chart .properties(width=, height=) values and re-render."
156+
)
157+
if _w < TW or _h < TH:
158+
_canvas = Image.new("RGB", (TW, TH), PAGE_BG)
159+
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
160+
_canvas.save(f"plot-{THEME}.png")
161+
162+
chart.save(f"plot-{THEME}.html")

0 commit comments

Comments
 (0)