{"spec_id":"map-marker-clustered","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nmap-marker-clustered: Clustered Marker Map\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 84/100 | Created: 2026-08-24\n\"\"\"\n\nimport os\nfrom collections import Counter\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\nfrom sklearn.cluster import DBSCAN\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\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 categorical palette — first series is always the brand green\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Data — synthetic coffee-shop chain locations across a metro area\nnp.random.seed(42)\n\nCATEGORIES = [\"Flagship\", \"Standard\", \"Kiosk\"]\nCATEGORY_WEIGHTS = [0.10, 0.55, 0.35]\n\n# (center_lat, center_lon, point_count, spread_degrees)\nhubs = [\n    (40.71, -73.99, 70, 0.010),  # downtown core\n    (40.80, -73.90, 55, 0.012),  # riverside district\n    (40.60, -73.85, 45, 0.012),  # uptown district\n    (40.55, -74.05, 50, 0.013),  # harbor district\n]\n\nlats, lons, cats = [], [], []\nfor center_lat, center_lon, point_count, spread in hubs:\n    lats.extend(np.random.normal(center_lat, spread, point_count))\n    lons.extend(np.random.normal(center_lon, spread, point_count))\n    cats.extend(np.random.choice(CATEGORIES, point_count, p=CATEGORY_WEIGHTS))\n\nn_outlying = 25\nlats.extend(np.random.uniform(40.45, 40.90, n_outlying))\nlons.extend(np.random.uniform(-74.15, -73.75, n_outlying))\ncats.extend(np.random.choice(CATEGORIES, n_outlying, p=CATEGORY_WEIGHTS))\n\nlats = np.array(lats)\nlons = np.array(lons)\ncats = np.array(cats)\n\n# Minimal geographic reference layer: a soft coastline band along the\n# southern edge, shaded on the water side, so the plot reads as a map\n# rather than a plain scatter even without a tile basemap. Built purely\n# from theme tokens (no new brand colors).\nlat_min, lat_max = float(lats.min()), float(lats.max())\nlon_min, lon_max = float(lons.min()), float(lons.max())\nlat_span = lat_max - lat_min\nWATER_FILL = \"#{:02X}{:02X}{:02X}\".format(\n    *(round(int(PAGE_BG[i : i + 2], 16) * 0.88 + int(INK[i : i + 2], 16) * 0.12) for i in (1, 3, 5))\n)\ncoastline_x = np.linspace(lon_min, lon_max, 24)\ncoastline_y = lat_min + lat_span * (0.03 + 0.025 * np.sin(np.linspace(0, 3 * np.pi, 24)))\nwater_band = list(zip(coastline_x.tolist(), coastline_y.tolist(), strict=True))\n\n# Cluster nearby stores the way a map would collapse them at a fixed zoom\n# level; isolated stores (label -1) stay as individual markers.\ncluster_labels = DBSCAN(eps=0.012, min_samples=8).fit_predict(np.column_stack([lats, lons]))\n\nINDIVIDUAL_RADIUS = 14\nCLUSTER_RADIUS_BASE = 22\nCLUSTER_RADIUS_SCALE = 9\n\nseries_points = {category: [] for category in CATEGORIES}\nfor label in sorted(set(cluster_labels)):\n    members = cluster_labels == label\n    if label == -1:\n        for lat, lon, category in zip(lats[members], lons[members], cats[members], strict=True):\n            series_points[category].append(\n                {\"value\": (lon, lat), \"node\": {\"r\": INDIVIDUAL_RADIUS}, \"tooltip\": f\"{category} store\"}\n            )\n    else:\n        count = int(members.sum())\n        dominant_category = Counter(cats[members]).most_common(1)[0][0]\n        centroid_lon = float(lons[members].mean())\n        centroid_lat = float(lats[members].mean())\n        radius = CLUSTER_RADIUS_BASE + CLUSTER_RADIUS_SCALE * count**0.5\n        series_points[dominant_category].append(\n            {\n                \"value\": (centroid_lon, centroid_lat),\n                \"node\": {\"r\": round(radius, 1)},\n                \"label\": str(count),\n                \"tooltip\": f\"{count} stores clustered here ({dominant_category} dominant)\",\n            }\n        )\n\n# Style — title kept compact because the mandated anyplot title is ~67 chars long\nTITLE = \"map-marker-clustered · python · pygal · anyplot.ai\"\ntitle_ratio = 67 / len(TITLE) if len(TITLE) > 67 else 1.0\ntitle_font_size = max(44, round(66 * title_ratio))\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    colors=(WATER_FILL, *IMPRINT_PALETTE[: len(CATEGORIES)]),\n    title_font_size=title_font_size,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    value_label_font_size=44,\n    dot_opacity=0.88,\n    stroke_width=2.5,\n)\n\n# Plot — an XY scatter stands in for the map surface, with a coastline\n# fill layer beneath it for geographic context; circle size encodes\n# cluster size and the printed number is the grouped-point count.\nchart = pygal.XY(\n    style=custom_style,\n    width=3200,\n    height=1800,\n    title=TITLE,\n    x_title=\"Longitude\",\n    y_title=\"Latitude\",\n    stroke=False,\n    show_dots=True,\n    print_labels=True,\n    print_values=False,\n    show_x_guides=True,\n    show_y_guides=True,\n    show_legend=True,\n)\n\n# Water-side fill beneath the coastline curve; title=None keeps it out\n# of the category legend since it isn't a data series.\nchart.add(None, water_band, stroke=True, fill=True, show_dots=False, stroke_style={\"width\": 2})\n\nfor category in CATEGORIES:\n    chart.add(category, series_points[category])\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}