{"spec_id":"heatmap-geographic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-geographic: Geographic Heatmap for Spatial Density\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 73/100 | Updated: 2026-05-19\n\"\"\"\n\nimport sys\n\nimport numpy as np\n\n\n# Remove current directory from path to avoid shadowing the pygal package\n_cwd = sys.path[0] if sys.path and sys.path[0] else \".\"\nif _cwd in sys.path:\n    sys.path.remove(_cwd)\n\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\nsys.path.insert(0, _cwd)\n\n\nclass GeoHeatmap(Graph):\n    \"\"\"Custom Geographic Heatmap for pygal - displays spatial density as colored grid.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self.heatmap_data = kwargs.pop(\"heatmap_data\", None)\n        self.lat_range = kwargs.pop(\"lat_range\", (-90, 90))\n        self.lon_range = kwargs.pop(\"lon_range\", (-180, 180))\n        self.colormap = kwargs.pop(\"colormap\", [\"#ffffb2\", \"#fecc5c\", \"#fd8d3c\", \"#f03b20\", \"#bd0026\"])\n        self.coastlines = kwargs.pop(\"coastlines\", [])\n        self.point_data = kwargs.pop(\"point_data\", None)\n        super().__init__(*args, **kwargs)\n\n    def _interpolate_color(self, value, min_val, max_val):\n        \"\"\"Interpolate color for smooth gradient.\"\"\"\n        if max_val == min_val:\n            return self.colormap[-1]\n\n        normalized = (value - min_val) / (max_val - min_val)\n        normalized = max(0, min(1, normalized))\n\n        pos = normalized * (len(self.colormap) - 1)\n        idx1 = int(pos)\n        idx2 = min(idx1 + 1, len(self.colormap) - 1)\n        frac = pos - idx1\n\n        c1 = self.colormap[idx1]\n        c2 = self.colormap[idx2]\n\n        r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)\n        r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16)\n\n        r = int(r1 + (r2 - r1) * frac)\n        g = int(g1 + (g2 - g1) * frac)\n        b = int(b1 + (b2 - b1) * frac)\n\n        return f\"#{r:02x}{g:02x}{b:02x}\"\n\n    def _plot(self):\n        \"\"\"Draw the geographic heatmap.\"\"\"\n        if self.heatmap_data is None:\n            return\n\n        heatmap = self.heatmap_data\n        n_rows, n_cols = heatmap.shape\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        # Layout margins\n        label_margin_left = 180\n        label_margin_right = 280\n        label_margin_top = 60\n        label_margin_bottom = 180\n\n        available_width = plot_width - label_margin_left - label_margin_right\n        available_height = plot_height - label_margin_top - label_margin_bottom\n\n        # Calculate cell size\n        cell_width = available_width / n_cols\n        cell_height = available_height / n_rows\n\n        x_offset = self.view.x(0) + label_margin_left\n        y_offset = self.view.y(n_rows) + label_margin_top\n\n        # Create group for the heatmap\n        plot_node = self.nodes[\"plot\"]\n        heatmap_group = self.svg.node(plot_node, class_=\"geo-heatmap\")\n\n        # Draw background rectangle for plot area\n        bg_rect = self.svg.node(\n            heatmap_group, \"rect\", x=x_offset, y=y_offset, width=available_width, height=available_height\n        )\n        bg_rect.set(\"fill\", \"#e8f4f8\")\n        bg_rect.set(\"stroke\", \"#333333\")\n        bg_rect.set(\"stroke-width\", \"2\")\n\n        # Draw grid lines\n        lat_min, lat_max = self.lat_range\n        lon_min, lon_max = self.lon_range\n\n        def lon_to_x(lon):\n            return x_offset + (lon - lon_min) / (lon_max - lon_min) * available_width\n\n        def lat_to_y(lat):\n            return y_offset + (1 - (lat - lat_min) / (lat_max - lat_min)) * available_height\n\n        # Vertical grid lines (longitude)\n        n_lon_lines = 7\n        for i in range(n_lon_lines):\n            lon = lon_min + (lon_max - lon_min) * i / (n_lon_lines - 1)\n            x = lon_to_x(lon)\n            line = self.svg.node(heatmap_group, \"line\", x1=x, y1=y_offset, x2=x, y2=y_offset + available_height)\n            line.set(\"stroke\", \"#cccccc\")\n            line.set(\"stroke-width\", \"1\")\n            line.set(\"stroke-opacity\", \"0.5\")\n\n        # Horizontal grid lines (latitude)\n        n_lat_lines = 7\n        for i in range(n_lat_lines):\n            lat = lat_min + (lat_max - lat_min) * i / (n_lat_lines - 1)\n            y = lat_to_y(lat)\n            line = self.svg.node(heatmap_group, \"line\", x1=x_offset, y1=y, x2=x_offset + available_width, y2=y)\n            line.set(\"stroke\", \"#cccccc\")\n            line.set(\"stroke-width\", \"1\")\n            line.set(\"stroke-opacity\", \"0.5\")\n\n        # Draw heatmap cells\n        all_values = heatmap.flatten()\n        positive_values = all_values[all_values > 0]\n        if len(positive_values) > 0:\n            min_val = positive_values.min()\n            max_val = positive_values.max()\n        else:\n            min_val, max_val = 0, 1\n\n        for i in range(n_rows):\n            for j in range(n_cols):\n                value = heatmap[i, j]\n                if value <= 0:\n                    continue\n\n                color = self._interpolate_color(value, min_val, max_val)\n                # Variable opacity based on value intensity for basemap visibility\n                opacity = 0.4 + 0.45 * (value - min_val) / (max_val - min_val) if max_val > min_val else 0.65\n\n                x = x_offset + j * cell_width\n                y = y_offset + (n_rows - 1 - i) * cell_height\n\n                rect = self.svg.node(heatmap_group, \"rect\", x=x, y=y, width=cell_width + 0.5, height=cell_height + 0.5)\n                rect.set(\"fill\", color)\n                rect.set(\"fill-opacity\", str(opacity))\n                rect.set(\"stroke\", \"none\")\n\n        # Draw coastlines\n        for coastline in self.coastlines:\n            if len(coastline) < 2:\n                continue\n            points = \" \".join([f\"{lon_to_x(lon)},{lat_to_y(lat)}\" for lon, lat in coastline])\n            polyline = self.svg.node(heatmap_group, \"polyline\", points=points)\n            polyline.set(\"fill\", \"none\")\n            polyline.set(\"stroke\", \"#333333\")\n            polyline.set(\"stroke-width\", \"3\")\n            polyline.set(\"stroke-opacity\", \"0.7\")\n\n        # Draw scatter points with improved visibility (larger radius for better visibility)\n        if self.point_data is not None:\n            for lon, lat in self.point_data:\n                cx = lon_to_x(lon)\n                cy = lat_to_y(lat)\n                circle = self.svg.node(heatmap_group, \"circle\", cx=cx, cy=cy, r=12)\n                circle.set(\"fill\", \"#306998\")\n                circle.set(\"fill-opacity\", \"0.7\")\n                circle.set(\"stroke\", \"#1a3a5c\")\n                circle.set(\"stroke-width\", \"1.5\")\n                circle.set(\"stroke-opacity\", \"0.9\")\n\n        # Draw axis labels\n        axis_font_size = 48\n        tick_font_size = 36\n\n        # X-axis label\n        text_node = self.svg.node(\n            heatmap_group, \"text\", x=x_offset + available_width / 2, y=y_offset + available_height + 130\n        )\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", \"#333333\")\n        text_node.set(\"style\", f\"font-size:{axis_font_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.text = \"Longitude (°)\"\n\n        # Y-axis label\n        text_node = self.svg.node(heatmap_group, \"text\", x=x_offset - 100, y=y_offset + available_height / 2)\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", \"#333333\")\n        text_node.set(\"style\", f\"font-size:{axis_font_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.set(\"transform\", f\"rotate(-90, {x_offset - 100}, {y_offset + available_height / 2})\")\n        text_node.text = \"Latitude (°)\"\n\n        # X-axis ticks\n        n_x_ticks = 6\n        for i in range(n_x_ticks):\n            lon = lon_min + (lon_max - lon_min) * i / (n_x_ticks - 1)\n            x = lon_to_x(lon)\n            text_node = self.svg.node(heatmap_group, \"text\", x=x, y=y_offset + available_height + 50)\n            text_node.set(\"text-anchor\", \"middle\")\n            text_node.set(\"fill\", \"#333333\")\n            text_node.set(\"style\", f\"font-size:{tick_font_size}px;font-family:sans-serif\")\n            text_node.text = f\"{lon:.0f}\"\n\n        # Y-axis ticks\n        n_y_ticks = 6\n        for i in range(n_y_ticks):\n            lat = lat_min + (lat_max - lat_min) * i / (n_y_ticks - 1)\n            y = lat_to_y(lat)\n            text_node = self.svg.node(heatmap_group, \"text\", x=x_offset - 20, y=y + tick_font_size * 0.35)\n            text_node.set(\"text-anchor\", \"end\")\n            text_node.set(\"fill\", \"#333333\")\n            text_node.set(\"style\", f\"font-size:{tick_font_size}px;font-family:sans-serif\")\n            text_node.text = f\"{lat:.0f}\"\n\n        # Draw colorbar on the right\n        colorbar_width = 50\n        colorbar_height = available_height * 0.7\n        colorbar_x = x_offset + available_width + 60\n        colorbar_y = y_offset + (available_height - colorbar_height) / 2\n\n        # Draw gradient colorbar\n        n_segments = 50\n        segment_height = colorbar_height / n_segments\n        for i in range(n_segments):\n            seg_value = min_val + (max_val - min_val) * (n_segments - 1 - i) / (n_segments - 1)\n            seg_color = self._interpolate_color(seg_value, min_val, max_val)\n            seg_y = colorbar_y + i * segment_height\n\n            self.svg.node(\n                heatmap_group,\n                \"rect\",\n                x=colorbar_x,\n                y=seg_y,\n                width=colorbar_width,\n                height=segment_height + 1,\n                fill=seg_color,\n            )\n\n        # Colorbar border\n        self.svg.node(\n            heatmap_group,\n            \"rect\",\n            x=colorbar_x,\n            y=colorbar_y,\n            width=colorbar_width,\n            height=colorbar_height,\n            fill=\"none\",\n            stroke=\"#333333\",\n        )\n\n        # Colorbar labels with 5 tick values\n        cb_label_size = 36\n        n_cb_ticks = 5\n        for i in range(n_cb_ticks):\n            tick_value = max_val - (max_val - min_val) * i / (n_cb_ticks - 1)\n            tick_y = colorbar_y + colorbar_height * i / (n_cb_ticks - 1)\n            text_node = self.svg.node(\n                heatmap_group, \"text\", x=colorbar_x + colorbar_width + 15, y=tick_y + cb_label_size * 0.35\n            )\n            text_node.set(\"fill\", \"#333333\")\n            text_node.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n            text_node.text = f\"{tick_value:.1f}\"\n\n        # Colorbar title\n        cb_title_size = 38\n        cb_title_x = colorbar_x + colorbar_width / 2\n        cb_title_y = colorbar_y - 30\n        text_node = self.svg.node(heatmap_group, \"text\", x=cb_title_x, y=cb_title_y)\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", \"#333333\")\n        text_node.set(\"style\", f\"font-size:{cb_title_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.text = \"Density\"\n\n        # Legend for scatter points (below colorbar)\n        legend_y = colorbar_y + colorbar_height + 60\n        legend_x = colorbar_x\n\n        # Legend marker (circle matching scatter points)\n        legend_circle = self.svg.node(heatmap_group, \"circle\", cx=legend_x + 12, cy=legend_y, r=12)\n        legend_circle.set(\"fill\", \"#306998\")\n        legend_circle.set(\"fill-opacity\", \"0.7\")\n        legend_circle.set(\"stroke\", \"#1a3a5c\")\n        legend_circle.set(\"stroke-width\", \"1.5\")\n\n        # Legend text\n        legend_text = self.svg.node(heatmap_group, \"text\", x=legend_x + 35, y=legend_y + 10)\n        legend_text.set(\"fill\", \"#333333\")\n        legend_text.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n        legend_text.text = \"Station\"\n\n    def _compute(self):\n        \"\"\"Compute the box for rendering.\"\"\"\n        n_rows = self.heatmap_data.shape[0] if self.heatmap_data is not None else 1\n        n_cols = self.heatmap_data.shape[1] if self.heatmap_data is not None else 1\n        self._box.xmin = 0\n        self._box.xmax = n_cols\n        self._box.ymin = 0\n        self._box.ymax = n_rows\n\n\n# Data: Simulated environmental monitoring stations across California\nnp.random.seed(42)\n\nn_points = 500\n\n# Create clusters representing different monitoring regions\n# Central California coast cluster\ncoast_lat = np.random.normal(36.5, 0.8, n_points // 3)\ncoast_lon = np.random.normal(-121.5, 0.5, n_points // 3)\n\n# Southern California cluster\nsocal_lat = np.random.normal(34.0, 0.6, n_points // 3)\nsocal_lon = np.random.normal(-118.0, 0.7, n_points // 3)\n\n# Northern California cluster\nnorcal_lat = np.random.normal(38.5, 0.5, n_points // 3 + n_points % 3)\nnorcal_lon = np.random.normal(-122.5, 0.4, n_points // 3 + n_points % 3)\n\n# Combine all clusters\nlatitudes = np.concatenate([coast_lat, socal_lat, norcal_lat])\nlongitudes = np.concatenate([coast_lon, socal_lon, norcal_lon])\n\n# Measurement values (air quality index readings)\nvalues = np.random.exponential(scale=50, size=len(latitudes)) + 20\n\n# Map boundaries for California\nlat_min, lat_max = 32.5, 42.0\nlon_min, lon_max = -125.0, -114.0\n\n# Create 2D histogram for density estimation\ngrid_resolution = 80\nlat_bins = np.linspace(lat_min, lat_max, grid_resolution)\nlon_bins = np.linspace(lon_min, lon_max, grid_resolution)\n\nheatmap, lat_edges, lon_edges = np.histogram2d(\n    latitudes, longitudes, bins=[lat_bins, lon_bins], weights=values, density=False\n)\n\n# Apply Gaussian smoothing for continuous appearance\nsigma = 2\nkernel_size = int(6 * sigma + 1)\nif kernel_size % 2 == 0:\n    kernel_size += 1\nkernel_x = np.arange(kernel_size) - kernel_size // 2\nkernel_1d = np.exp(-(kernel_x**2) / (2 * sigma**2))\nkernel_1d = kernel_1d / kernel_1d.sum()\n\nheatmap_smooth = np.apply_along_axis(lambda row: np.convolve(row, kernel_1d, mode=\"same\"), axis=0, arr=heatmap)\nheatmap_smooth = np.apply_along_axis(lambda col: np.convolve(col, kernel_1d, mode=\"same\"), axis=1, arr=heatmap_smooth)\n\n# California coastline approximation\ncoast_lons = [\n    -124.4,\n    -124.2,\n    -123.8,\n    -122.4,\n    -122.0,\n    -121.5,\n    -121.0,\n    -120.5,\n    -120.0,\n    -119.5,\n    -119.0,\n    -118.5,\n    -118.0,\n    -117.5,\n    -117.2,\n    -117.0,\n    -117.1,\n    -117.3,\n]\ncoast_lats = [\n    42.0,\n    40.5,\n    39.0,\n    37.8,\n    37.5,\n    36.8,\n    36.5,\n    35.5,\n    35.0,\n    34.5,\n    34.2,\n    34.0,\n    33.8,\n    33.2,\n    33.0,\n    32.7,\n    32.5,\n    32.5,\n]\ncoastline_west = list(zip(coast_lons, coast_lats, strict=True))\n\neast_lons = [-117.3, -117.0, -116.5, -115.5, -114.6, -114.6, -120.0, -120.0, -121.0, -122.0, -123.0, -124.2, -124.4]\neast_lats = [32.5, 33.0, 33.5, 34.0, 34.8, 36.0, 39.0, 40.0, 41.0, 41.5, 42.0, 42.0, 42.0]\ncoastline_east = list(zip(east_lons, east_lats, strict=True))\n\ncoastlines = [coastline_west, coastline_east]\n\n# Point data for scatter overlay\npoint_data = list(zip(longitudes, latitudes, strict=True))\n\n# YlOrRd colormap\ncolormap = [\"#ffffb2\", \"#fed976\", \"#feb24c\", \"#fd8d3c\", \"#fc4e2a\", \"#e31a1c\", \"#b10026\"]\n\n# Custom style\ncustom_style = Style(\n    background=\"white\",\n    plot_background=\"#e8f4f8\",\n    foreground=\"#333333\",\n    foreground_strong=\"#333333\",\n    foreground_subtle=\"#666666\",\n    colors=(\"#306998\",),\n    title_font_size=64,\n    legend_font_size=40,\n    label_font_size=42,\n    value_font_size=36,\n    font_family=\"sans-serif\",\n)\n\n# Create heatmap chart\nchart = GeoHeatmap(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"heatmap-geographic · pygal · pyplots.ai\",\n    heatmap_data=heatmap_smooth,\n    lat_range=(lat_min, lat_max),\n    lon_range=(lon_min, lon_max),\n    colormap=colormap,\n    coastlines=coastlines,\n    point_data=point_data,\n    show_legend=False,\n    margin=100,\n    margin_top=160,\n    margin_bottom=80,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\n# Add a dummy series to trigger _plot\nchart.add(\"\", [0])\n\n# Save PNG output only\nchart.render_to_png(\"plot.png\")\n"}