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