{"spec_id":"hexbin-map-geographic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nhexbin-map-geographic: Hexagonal Binning Map\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-27\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport plotly.express as px\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nMAP_STYLE = \"carto-positron\" if THEME == \"light\" else \"carto-darkmatter\"\nHEX_BORDER = \"rgba(30,30,30,0.7)\" if THEME == \"light\" else \"rgba(220,220,220,0.5)\"\n\n# imprint_seq — the only allowed sequential colormap\nimprint_seq = [[0.0, \"#009E73\"], [1.0, \"#4467A3\"]]\n\n# Data: NYC taxi pickup locations with fare values\nnp.random.seed(42)\nn_points = 5000\nlat0, lon0 = 40.7580, -73.9855\n\ncluster_lats = lat0 + np.random.uniform(-0.08, 0.08, 8)\ncluster_lons = lon0 + np.random.uniform(-0.10, 0.10, 8)\nassignments = np.random.choice(8, n_points, p=np.random.dirichlet(np.ones(8)))\nlats = cluster_lats[assignments] + np.random.normal(0, 0.015, n_points)\nlons = cluster_lons[assignments] + np.random.normal(0, 0.020, n_points)\nfares = np.random.exponential(15, n_points) + 5\n\ndf = pd.DataFrame({\"lat\": lats, \"lon\": lons, \"fare\": fares})\n\n# Hexagonal binning: pointy-top hexagons using axial coordinate system\ncos_lat = np.cos(np.radians(lat0))\nhex_size = 0.007  # local-degree units (~0.5 km cells at NYC latitude)\nsqrt3 = np.sqrt(3)\n\n# Project to flat local coords (lon adjusted for latitude compression)\nxs = (df[\"lon\"].values - lon0) * cos_lat\nys = df[\"lat\"].values - lat0\n\n# Convert to axial (q, r) — pointy-top hex\nq_frac = (xs * sqrt3 / 3 - ys / 3) / hex_size\nr_frac = ys * 2 / 3 / hex_size\ns_frac = -q_frac - r_frac\n\n# Cube rounding to nearest hex cell\nrq = np.round(q_frac).astype(int)\nrr = np.round(r_frac).astype(int)\nrs = np.round(s_frac).astype(int)\n\ndq = np.abs(rq - q_frac)\ndr = np.abs(rr - r_frac)\nds = np.abs(rs - s_frac)\n\nmask_q = (dq > dr) & (dq > ds)\nmask_r = (~mask_q) & (dr > ds)\nrq[mask_q] = -rr[mask_q] - rs[mask_q]\nrr[mask_r] = -rq[mask_r] - rs[mask_r]\n\ndf[\"hex_id\"] = [f\"{q}_{r}\" for q, r in zip(rq, rr, strict=False)]\n\n# Aggregate by hex cell: count, sum, and mean of fares\nhex_agg = (\n    df.groupby(\"hex_id\")\n    .agg(count=(\"fare\", \"size\"), total_fare=(\"fare\", \"sum\"), mean_fare=(\"fare\", \"mean\"))\n    .reset_index()\n)\nhex_agg[[\"q\", \"r\"]] = hex_agg[\"hex_id\"].str.split(\"_\", n=1, expand=True).astype(int)\n\n# Vectorized GeoJSON polygon construction (no function definitions)\nq_vals = hex_agg[\"q\"].values\nr_vals = hex_agg[\"r\"].values\n\n# Hex centers in flat local coords\ncx_vals = hex_size * (sqrt3 * q_vals + sqrt3 / 2 * r_vals)\ncy_vals = hex_size * 1.5 * r_vals\n\n# 6 corner angles for pointy-top hexagon (30°, 90°, 150°, 210°, 270°, 330°)\ncorner_angles = np.radians(30 + 60 * np.arange(6))\ncorner_dx = hex_size * np.cos(corner_angles)\ncorner_dy = hex_size * np.sin(corner_angles)\n\n# Shape: (n_hex, 6) → lon/lat for each corner\ncorner_lons = lon0 + (cx_vals[:, None] + corner_dx[None, :]) / cos_lat\ncorner_lats = lat0 + cy_vals[:, None] + corner_dy[None, :]\n\n# Close the polygon ring: (n_hex, 7, 2)\nring_lons = np.concatenate([corner_lons, corner_lons[:, :1]], axis=1)\nring_lats = np.concatenate([corner_lats, corner_lats[:, :1]], axis=1)\nrings = np.stack([ring_lons, ring_lats], axis=-1)\n\ngeojson = {\n    \"type\": \"FeatureCollection\",\n    \"features\": [\n        {\n            \"type\": \"Feature\",\n            \"id\": hex_agg[\"hex_id\"].iloc[i],\n            \"properties\": {},\n            \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [rings[i].tolist()]},\n        }\n        for i in range(len(hex_agg))\n    ],\n}\n\n# Title with length-aware font scaling\ntitle = \"NYC Taxi Fares · hexbin-map-geographic · python · plotly · anyplot.ai\"\nn_chars = len(title)\ntitle_fontsize = max(11, round(16 * 67 / n_chars)) if n_chars > 67 else 16\n\n# Plot\nfig = px.choropleth_map(\n    hex_agg,\n    geojson=geojson,\n    locations=\"hex_id\",\n    color=\"mean_fare\",\n    color_continuous_scale=imprint_seq,\n    opacity=0.75,\n    center={\"lat\": lat0, \"lon\": lon0},\n    zoom=11,\n    map_style=MAP_STYLE,\n    hover_data={\"hex_id\": False, \"count\": \":,\", \"mean_fare\": \":$.2f\", \"total_fare\": \":$,.0f\"},\n)\n\nfig.update_traces(marker_line_width=1.5, marker_line_color=HEX_BORDER)\n\nfig.update_layout(\n    autosize=False,\n    paper_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    title={\"text\": title, \"font\": {\"size\": title_fontsize, \"color\": INK}, \"x\": 0.5, \"xanchor\": \"center\"},\n    coloraxis_colorbar={\n        \"title\": {\"text\": \"Avg Fare ($)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"tickprefix\": \"$\",\n        \"len\": 0.7,\n        \"thickness\": 20,\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n    },\n    margin={\"l\": 20, \"r\": 40, \"t\": 80, \"b\": 20},\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}