{"spec_id":"hexbin-map-geographic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nhexbin-map-geographic: Hexagonal Binning Map\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-05-27\n\"\"\"\n\nimport math\nimport os\nimport re\nimport sys\nfrom collections import defaultdict\n\n\n# Fix module name conflict (this file is named pygal.py)\n_cwd = sys.path[0] if sys.path and sys.path[0] else None\nif _cwd:\n    sys.path.remove(_cwd)\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nif _cwd:\n    sys.path.insert(0, _cwd)\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# imprint_seq: #009E73 → #4467A3 (5 evenly-spaced stops, low → high density)\nseq_stops = (\"#009E73\", \"#11907F\", \"#22838B\", \"#337597\", \"#4467A3\")\nn_density_bins = 5\n\n# Data — NYC taxi pickup locations (Manhattan)\nnp.random.seed(42)\nn_points = 5000\nlat_min, lat_max = 40.70, 40.82\nlon_min, lon_max = -74.02, -73.93\n\nc1_lat = np.random.normal(40.758, 0.015, n_points // 3)\nc1_lon = np.random.normal(-73.985, 0.01, n_points // 3)\nc1_vals = np.random.exponential(25, n_points // 3)\n\nc2_lat = np.random.normal(40.710, 0.012, n_points // 3)\nc2_lon = np.random.normal(-74.010, 0.008, n_points // 3)\nc2_vals = np.random.exponential(35, n_points // 3)\n\nc3_lat = np.random.normal(40.775, 0.018, n_points // 3)\nc3_lon = np.random.normal(-73.960, 0.012, n_points // 3)\nc3_vals = np.random.exponential(20, n_points // 3)\n\nlat = np.clip(np.concatenate([c1_lat, c2_lat, c3_lat]), lat_min, lat_max)\nlon = np.clip(np.concatenate([c1_lon, c2_lon, c3_lon]), lon_min, lon_max)\nvalues = np.concatenate([c1_vals, c2_vals, c3_vals])\n\n# Hexagonal binning with count and fare aggregation\ngridsize = 25\nx_arr = np.asarray(lon)\ny_arr = np.asarray(lat)\nx_min_v, x_max_v = x_arr.min(), x_arr.max()\ny_min_v = y_arr.min()\nhex_width = (x_max_v - x_min_v) / gridsize\nhex_height = hex_width * np.sqrt(3) / 2\n\nbins = defaultdict(lambda: {\"count\": 0, \"sum\": 0.0})\nfor xi, yi, vi in zip(x_arr, y_arr, values, strict=True):\n    col = (xi - x_min_v) / hex_width\n    row_offset = (int(col) % 2) * 0.5\n    row = (yi - y_min_v) / hex_height - row_offset\n    col_idx = int(round(col))\n    row_idx = int(round(row))\n    bins[(col_idx, row_idx)][\"count\"] += 1\n    bins[(col_idx, row_idx)][\"sum\"] += vi\n\nhex_data = []\nfor (col_idx, row_idx), data in bins.items():\n    cx = x_min_v + col_idx * hex_width\n    row_offset = (col_idx % 2) * 0.5\n    cy = y_min_v + (row_idx + row_offset) * hex_height\n    count = data[\"count\"]\n    total = data[\"sum\"]\n    mean = total / count if count > 0 else 0\n    hex_data.append({\"lon\": cx, \"lat\": cy, \"count\": count, \"sum\": total, \"mean\": mean})\n\ncounts = np.array([h[\"count\"] for h in hex_data])\n\n# Percentile-based bin edges for balanced distribution\nbin_edges = np.percentile(counts, [0, 20, 40, 60, 80, 100])\nfor i in range(1, len(bin_edges)):\n    if bin_edges[i] <= bin_edges[i - 1]:\n        bin_edges[i] = bin_edges[i - 1] + 1\n\n# Shortened labels to prevent legend truncation\nbin_labels = [\n    f\"Low ({int(bin_edges[0])}–{int(bin_edges[1])})\",\n    f\"Med-Low ({int(bin_edges[1])}–{int(bin_edges[2])})\",\n    f\"Medium ({int(bin_edges[2])}–{int(bin_edges[3])})\",\n    f\"Med-High ({int(bin_edges[3])}–{int(bin_edges[4])})\",\n    f\"High ({int(bin_edges[4])}+)\",\n]\n\n# Geographic outlines (Manhattan island + waterways)\nmanhattan_outline = [\n    (-74.020, 40.700),\n    (-74.010, 40.705),\n    (-74.000, 40.710),\n    (-73.975, 40.725),\n    (-73.970, 40.750),\n    (-73.965, 40.775),\n    (-73.940, 40.800),\n    (-73.930, 40.815),\n    (-73.935, 40.820),\n    (-73.943, 40.830),\n    (-73.950, 40.822),\n    (-73.970, 40.810),\n    (-73.990, 40.770),\n    (-74.010, 40.740),\n    (-74.015, 40.720),\n    (-74.020, 40.700),\n]\nhudson_river = [\n    (-74.035, 40.690),\n    (-74.025, 40.705),\n    (-74.018, 40.720),\n    (-74.015, 40.750),\n    (-74.005, 40.780),\n    (-73.985, 40.810),\n    (-73.970, 40.835),\n]\neast_river = [\n    (-73.935, 40.695),\n    (-73.940, 40.720),\n    (-73.945, 40.755),\n    (-73.925, 40.785),\n    (-73.915, 40.810),\n    (-73.920, 40.835),\n]\n\ntitle = \"hexbin-map-geographic · python · pygal · anyplot.ai\"\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    guide_stroke_color=\"transparent\",\n    colors=(INK_MUTED, INK_MUTED, INK_MUTED) + seq_stops,\n    opacity=0.85,\n    opacity_hover=0.95,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n)\n\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"Longitude (°)\",\n    y_title=\"Latitude (°)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=5,\n    legend_box_size=28,\n    stroke=False,\n    dots_size=30,\n    show_x_guides=False,\n    show_y_guides=False,\n    explicit_size=True,\n    print_values=False,\n    xrange=(lon_min - 0.015, lon_max + 0.015),\n    range=(lat_min - 0.008, lat_max + 0.008),\n)\n\n# Geographic boundaries (excluded from legend via None label)\nchart.add(None, manhattan_outline, stroke=True, dots_size=0, show_dots=False, fill=False, stroke_width=4)\nchart.add(None, hudson_river, stroke=True, dots_size=0, show_dots=False, fill=False, stroke_width=3)\nchart.add(None, east_river, stroke=True, dots_size=0, show_dots=False, fill=False, stroke_width=3)\n\n# Density bins with size encoding (larger hexagons = more pickups)\nseries_data = [[] for _ in range(n_density_bins)]\nfor h in hex_data:\n    hx, hy = h[\"lon\"], h[\"lat\"]\n    count = h[\"count\"]\n    total = h[\"sum\"]\n    mean = h[\"mean\"]\n    bin_idx = 0\n    for i in range(1, n_density_bins):\n        if count >= bin_edges[i]:\n            bin_idx = i\n    tooltip = f\"Count: {count} | Fares: ${total:.0f} total, ${mean:.2f} avg | ({hy:.4f}°N, {abs(hx):.4f}°W)\"\n    series_data[bin_idx].append({\"value\": (float(hx), float(hy)), \"label\": tooltip})\n\ndot_sizes = [26, 34, 44, 54, 66]\nfor i in range(n_density_bins):\n    if not series_data[i]:\n        series_data[i].append({\"value\": (-99, 0), \"label\": \"No data\"})\n    chart.add(bin_labels[i], series_data[i], dots_size=dot_sizes[i])\n\n\ndef circles_to_hexagons(svg_text):\n    \"\"\"Post-process SVG: replace circular dot markers with flat-top hexagonal polygons.\"\"\"\n\n    def replace_one(m):\n        tag = m.group(0)\n        cx_m = re.search(r'\\bcx=\"([^\"]+)\"', tag)\n        cy_m = re.search(r'\\bcy=\"([^\"]+)\"', tag)\n        r_m = re.search(r'\\br=\"([^\"]+)\"', tag)\n        if not (cx_m and cy_m and r_m):\n            return tag\n        cx = float(cx_m.group(1))\n        cy = float(cy_m.group(1))\n        r = float(r_m.group(1))\n        # Flat-top hexagon: vertex angles 30°, 90°, 150°, 210°, 270°, 330°\n        pts = \" \".join(\n            f\"{cx + r * math.cos(math.radians(60 * k + 30)):.2f},{cy + r * math.sin(math.radians(60 * k + 30)):.2f}\"\n            for k in range(6)\n        )\n        poly = tag.replace(\"<circle\", \"<polygon\", 1)\n        poly = re.sub(r'\\bcx=\"[^\"]*\"\\s*', \"\", poly)\n        poly = re.sub(r'\\bcy=\"[^\"]*\"\\s*', \"\", poly)\n        poly = re.sub(r'\\br=\"[^\"]*\"', f'points=\"{pts}\"', poly)\n        return poly\n\n    return re.sub(r\"<circle\\b[^>]*/>\", replace_one, svg_text)\n\n\n# Render SVG, convert circle markers to hexagonal polygons, then produce PNG\nsvg_bytes = chart.render()\nsvg_str = svg_bytes.decode(\"utf-8\")\nmodified_svg = circles_to_hexagons(svg_str)\nmodified_bytes = modified_svg.encode(\"utf-8\")\n\ncairosvg.svg2png(bytestring=modified_bytes, write_to=f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(modified_bytes)\n"}