{"spec_id":"hexbin-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nhexbin-basic: Basic Hexbin Plot\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 85/100 | Created: 2026-05-29\n\"\"\"\n\nimport importlib.util\nimport math\nimport os\nimport re\nimport sys\n\nimport cairosvg\nimport numpy as np\n\n\n# Ensure we import the installed pygal package, not this file\npygal_spec = importlib.util.find_spec(\"pygal\")\nif pygal_spec and pygal_spec.origin != __file__:\n    import pygal\n    from pygal.style import Style\nelse:\n    cwd = os.getcwd()\n    sys.path = [p for p in sys.path if os.path.abspath(p) != cwd]\n    try:\n        import pygal\n        from pygal.style import Style\n    finally:\n        sys.path.insert(0, cwd)\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint style guide)\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\n# Imprint sequential colormap: brand green (#009E73) → blue (#4467A3), sparse → dense\nN_LEVELS = 6\ndensity_colors = tuple(\n    f\"#{round(0x00 + (0x44 - 0x00) * i / (N_LEVELS - 1)):02X}\"\n    f\"{round(0x9E + (0x67 - 0x9E) * i / (N_LEVELS - 1)):02X}\"\n    f\"{round(0x73 + (0xA3 - 0x73) * i / (N_LEVELS - 1)):02X}\"\n    for i in range(N_LEVELS)\n)\n\n# Data: urban air quality sensor network with three pollution hotspots\nnp.random.seed(42)\ncore_x = np.random.randn(2500) * 0.6 + 1.5\ncore_y = np.random.randn(2500) * 0.6 + 3.0\nindustrial_x = np.random.randn(1200) * 1.1 - 2.5\nindustrial_y = np.random.randn(1200) * 0.9 - 1.0\nhighway_x = np.random.randn(600) * 0.4 + 5.0\nhighway_y = np.random.randn(600) * 1.8 + 1.0\nbg_x = np.random.uniform(-4.0, 6.5, 150)\nbg_y = np.random.uniform(-3.0, 5.5, 150)\n\nsensor_x = np.concatenate([core_x, industrial_x, highway_x, bg_x])\nsensor_y = np.concatenate([core_y, industrial_y, highway_y, bg_y])\n\n# Hexagonal binning: assign points to tessellating hex cells, count per cell\nGRIDSIZE = 20\nPAD = 0.2\nx_min, x_max = sensor_x.min() - PAD, sensor_x.max() + PAD\ny_min = sensor_y.min() - PAD\n\nhex_width = (x_max - x_min) / GRIDSIZE  # pointy-top: x-spacing = sqrt(3)*R\nhex_height = hex_width * 2 / np.sqrt(3)  # y-spacing = 2R, circumradius R = hex_width/sqrt(3)\n\nrows = ((sensor_y - y_min) / hex_height).astype(int)\nodd_shift = np.where(rows % 2 == 1, 0.5, 0.0)\ncols = ((sensor_x - x_min) / hex_width - odd_shift).astype(int)\n\ncell_ids = cols * 10000 + rows\nunique_cells, counts = np.unique(cell_ids, return_counts=True)\ncell_cols = unique_cells // 10000\ncell_rows = unique_cells % 10000\ncx = x_min + (cell_cols + np.where(cell_rows % 2 == 1, 0.5, 0.0)) * hex_width + hex_width / 2\ncy = y_min + cell_rows * hex_height + hex_height / 2\n\n# Log-spaced density thresholds → 6 colour levels\nc_min, c_max = float(counts.min()), float(counts.max())\nedges = np.logspace(np.log10(c_min), np.log10(c_max + 1), N_LEVELS + 1)\nedges[0] = c_min\nedges[-1] = c_max + 1\n\nlevel_names = [\"Sparse\", \"Low\", \"Medium\", \"Moderate\", \"Dense\", \"Hotspot\"]\nlabels = [f\"{level_names[i]} ({int(edges[i])}–{int(edges[i + 1])})\" for i in range(N_LEVELS)]\n\nseries_data: list[list] = [[] for _ in range(N_LEVELS)]\nfor x, y, cnt in zip(cx, cy, counts, strict=True):\n    level = min(int(np.searchsorted(edges[1:], cnt)), N_LEVELS - 1)\n    series_data[level].append({\"value\": (round(float(x), 2), round(float(y), 2)), \"label\": f\"{int(cnt)} readings\"})\n\n# Imprint sequential palette, theme-adaptive chrome\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    colors=density_colors,\n    opacity=0.95,\n    opacity_hover=1.0,\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    title_font_family=\"sans-serif\",\n    label_font_family=\"sans-serif\",\n    major_label_font_family=\"sans-serif\",\n    legend_font_family=\"sans-serif\",\n)\n\n# Axis viewport: 2nd/98th percentile + padding to trim outlier tails\nx_lo = float(np.percentile(sensor_x, 2)) - 0.4\nx_hi = float(np.percentile(sensor_x, 98)) + 0.4\ny_lo = float(np.percentile(sensor_y, 2)) - 0.4\ny_hi = float(np.percentile(sensor_y, 98)) + 0.4\n\n# Uniform circumradius for tessellating hexagons (SVG user units = pixels at 3200px width)\n# Estimate: plot area ≈ 2900px wide, x_range ≈ 10.8 km, gridsize=20 →\n# hex_width_px = 2900/20 = 145, circumradius = 145/sqrt(3) ≈ 84\n# Use 56 for crisp gaps between hexes (standard hexbin look) and title headroom\nHEX_R = 56\n\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"hexbin-basic · python · pygal · anyplot.ai\",\n    x_title=\"Sensor Grid X (km)\",\n    y_title=\"Sensor Grid Y (km)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    legend_box_size=24,\n    stroke=False,\n    dots_size=HEX_R,\n    show_x_guides=False,\n    show_y_guides=False,\n    xrange=(x_lo, x_hi),\n    range=(y_lo, y_hi),\n    truncate_legend=-1,\n    print_values=False,\n    human_readable=True,\n    x_labels_major_count=6,\n    y_labels_major_count=6,\n    show_minor_x_labels=False,\n    show_minor_y_labels=False,\n    value_formatter=lambda v: f\"{v:.1f} km\",\n)\n\nfor i in range(N_LEVELS):\n    if series_data[i]:\n        chart.add(labels[i], series_data[i], dots_size=HEX_R)\n\n# SVG post-processing: replace circle markers with hexagonal polygons\nsvg_raw = chart.render()\nsvg_text = svg_raw.decode(\"utf-8\") if isinstance(svg_raw, bytes) else svg_raw\n\n\ndef _circle_to_hex(match: re.Match) -> str:\n    tag = match.group(0)\n    cx_m = re.search(r'cx=\"([\\d.e+-]+)\"', tag)\n    cy_m = re.search(r'cy=\"([\\d.e+-]+)\"', tag)\n    r_m = re.search(r\"\\br=\\\"([\\d.e+-]+)\\\"\", tag)\n    if not (cx_m and cy_m and r_m):\n        return tag\n    r_v = float(r_m.group(1))\n    if r_v < 1.0:  # skip tiny decorative circles (legend dots, etc.)\n        return tag\n    xc, yc = float(cx_m.group(1)), float(cy_m.group(1))\n    pts = \" \".join(\n        f\"{xc + r_v * math.cos(math.radians(a)):.2f},{yc + r_v * math.sin(math.radians(a)):.2f}\"\n        for a in range(0, 360, 60)\n    )\n    result = re.sub(r'\\bcx=\"[\\d.e+-]+\"', \"\", tag)\n    result = re.sub(r'\\bcy=\"[\\d.e+-]+\"', \"\", result)\n    result = re.sub(r\"\\br=\\\"[\\d.e+-]+\\\"\", f'points=\"{pts}\"', result, count=1)\n    return result.replace(\"<circle\", \"<polygon\")\n\n\nsvg_hex = re.sub(r\"<circle[^>]*/>\", _circle_to_hex, svg_text)\n\n# Inject a clip-path for the data area so hexagons don't bleed into title/margins.\n# Extract the plot group transform to find the data area bounds in SVG coordinates.\nplot_transform_m = re.search(r'<g transform=\"translate\\(([\\d.]+),\\s*([\\d.]+)\\)\" class=\"plot\"', svg_hex)\nplot_bg_m = re.search(r'<rect x=\"0\" y=\"0\" width=\"([\\d.]+)\" height=\"([\\d.]+)\" class=\"background\"', svg_hex)\nif plot_transform_m and plot_bg_m:\n    px = float(plot_transform_m.group(1))\n    py = float(plot_transform_m.group(2))\n    pw = float(plot_bg_m.group(1))\n    ph = float(plot_bg_m.group(2))\n    clip_id = \"hex-data-clip\"\n    clip_def = (\n        f'<clipPath id=\"{clip_id}\"><rect x=\"{px:.1f}\" y=\"{py:.1f}\" width=\"{pw:.1f}\" height=\"{ph:.1f}\"/></clipPath>'\n    )\n    # Insert clipPath into <defs>\n    svg_hex = svg_hex.replace(\"</defs>\", clip_def + \"</defs>\", 1)\n    # Apply clip to each series <g> group (leaves axis/label groups unclipped)\n    svg_hex = re.sub(\n        r'(<g class=\"series [^\"]*\">)', lambda m: m.group(0).replace(\">\", f' clip-path=\"url(#{clip_id})\">', 1), svg_hex\n    )\n    # Subtle plot frame — inner background rect from plot group (width/height relative to transform)\n    inner_m = re.search(r'<rect[^>]+class=\"plot_background\"[^>]*/>', svg_hex) or re.search(\n        r'<rect[^>]+class=\"graph\"[^>]*/>', svg_hex\n    )\n    if inner_m:\n        inner_tag = inner_m.group(0)\n        iw_m = re.search(r'width=\"([\\d.]+)\"', inner_tag)\n        ih_m = re.search(r'height=\"([\\d.]+)\"', inner_tag)\n        if iw_m and ih_m:\n            iw, ih = float(iw_m.group(1)), float(ih_m.group(1))\n            frame_svg = (\n                f'<rect x=\"{px:.1f}\" y=\"{py:.1f}\" width=\"{iw:.1f}\" height=\"{ih:.1f}\" '\n                f'fill=\"none\" stroke=\"{INK_MUTED}\" stroke-width=\"1.5\" opacity=\"0.3\"/>'\n            )\n            svg_hex = svg_hex.replace(\"</svg>\", frame_svg + \"\\n</svg>\")\n\n# Save interactive HTML and static PNG\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(svg_hex)\n\ncairosvg.svg2png(bytestring=svg_hex.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n"}