{"spec_id":"map-marker-clustered","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nmap-marker-clustered: Clustered Marker Map\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-23\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove this script's own directory from sys.path to avoid shadowing the installed altair package\nsys.path = [p for p in sys.path if p not in (\"\", os.path.dirname(os.path.abspath(__file__)))]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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_FILL = \"#E8E6DF\" if THEME == \"light\" else \"#2A2A27\"\nMAP_STROKE = \"#B8B7B0\" if THEME == \"light\" else \"#4A4A44\"\n\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\"]  # imprint canonical slot order\n\n# Data — store locations across the United States\nnp.random.seed(42)\n\ncities = [\n    (40.7128, -74.0060, \"New York\", \"retail\"),\n    (34.0522, -118.2437, \"Los Angeles\", \"retail\"),\n    (41.8781, -87.6298, \"Chicago\", \"food\"),\n    (29.7604, -95.3698, \"Houston\", \"food\"),\n    (33.4484, -112.0740, \"Phoenix\", \"services\"),\n    (39.7392, -104.9903, \"Denver\", \"services\"),\n    (47.6062, -122.3321, \"Seattle\", \"retail\"),\n    (25.7617, -80.1918, \"Miami\", \"food\"),\n    (42.3601, -71.0589, \"Boston\", \"retail\"),\n    (38.9072, -77.0369, \"Washington DC\", \"services\"),\n]\n\nn_points = 500\nlats, lons, store_labels, cats = [], [], [], []\nfor i in range(n_points):\n    city = cities[i % len(cities)]\n    # Tighter noise (std=0.8) + clamp to CONUS bounds keeps all points within the US basemap\n    lats.append(float(np.clip(city[0] + np.random.normal(0, 0.8), 24.0, 49.0)))\n    lons.append(float(np.clip(city[1] + np.random.normal(0, 0.8), -125.0, -66.0)))\n    store_labels.append(f\"Store {i + 1}\")\n    cats.append(city[3])\n\ndf = pd.DataFrame({\"lat\": lats, \"lon\": lons, \"label\": store_labels, \"category\": cats})\n\n# Grid-based clustering — no external geo-dependencies\ngrid_size = 2.5  # degrees\ndf[\"lat_bin\"] = (df[\"lat\"] / grid_size).round() * grid_size\ndf[\"lon_bin\"] = (df[\"lon\"] / grid_size).round() * grid_size\n\ncluster_summary = (\n    df.groupby([\"lat_bin\", \"lon_bin\"])\n    .agg(\n        lat=(\"lat\", \"mean\"),\n        lon=(\"lon\", \"mean\"),\n        count=(\"label\", \"count\"),\n        dominant_category=(\"category\", lambda x: x.mode().iloc[0]),\n    )\n    .reset_index()\n)\ncluster_summary[\"marker_size\"] = np.log1p(cluster_summary[\"count\"]) * 150 + 100\n\n# Spider-line data: for each individual point, one segment to its cluster centroid\n# Used in HTML to reveal member locations on cluster hover\ncluster_centers = cluster_summary[[\"lat_bin\", \"lon_bin\", \"lat\", \"lon\"]].rename(columns={\"lat\": \"clat\", \"lon\": \"clon\"})\ndf_linked = df.merge(cluster_centers, on=[\"lat_bin\", \"lon_bin\"])\n\nlines_rows = []\nfor idx, row in df_linked.iterrows():\n    link_id = f\"lk{idx}\"\n    lines_rows.append(\n        {\n            \"lon\": row[\"clon\"],\n            \"lat\": row[\"clat\"],\n            \"lat_bin\": row[\"lat_bin\"],\n            \"lon_bin\": row[\"lon_bin\"],\n            \"link_id\": link_id,\n        }\n    )\n    lines_rows.append(\n        {\"lon\": row[\"lon\"], \"lat\": row[\"lat\"], \"lat_bin\": row[\"lat_bin\"], \"lon_bin\": row[\"lon_bin\"], \"link_id\": link_id}\n    )\n\ndf_lines = pd.DataFrame(lines_rows)\n\n# US states basemap (Vega CDN)\nus_10m_url = \"https://cdn.jsdelivr.net/npm/vega-datasets@2/data/us-10m.json\"\nstates = alt.topo_feature(us_10m_url, \"states\")\n\nbackground = (\n    alt.Chart(states)\n    .mark_geoshape(fill=MAP_FILL, stroke=MAP_STROKE, strokeWidth=0.5)\n    .project(type=\"albersUsa\")\n    .properties(width=620, height=320)\n)\n\ncategory_colors = alt.Scale(\n    domain=[\"retail\", \"food\", \"services\"], range=[IMPRINT[0], IMPRINT[1], IMPRINT[2]]\n)\n\n# Hover selection on cluster circles — gates spider lines and member-point reveal\ncluster_hover = alt.selection_point(fields=[\"lat_bin\", \"lon_bin\"], on=\"mouseover\", empty=False)\n\n# Spider lines from cluster centroid to each member point (HTML interactive layer)\nspider_lines = (\n    alt.Chart(df_lines)\n    .mark_line(strokeWidth=0.8, opacity=0.5, color=INK_SOFT)\n    .encode(longitude=\"lon:Q\", latitude=\"lat:Q\", detail=\"link_id:N\")\n    .transform_filter(cluster_hover)\n    .project(type=\"albersUsa\")\n)\n\n# Individual member points revealed when their parent cluster is hovered\nhover_points = (\n    alt.Chart(df_linked)\n    .mark_circle(size=25, opacity=0.75, stroke=PAGE_BG, strokeWidth=0.5)\n    .encode(\n        longitude=\"lon:Q\",\n        latitude=\"lat:Q\",\n        color=alt.Color(\"category:N\", scale=category_colors),\n        tooltip=[\n            alt.Tooltip(\"label:N\", title=\"Store\"),\n            alt.Tooltip(\"category:N\", title=\"Type\"),\n            alt.Tooltip(\"lat:Q\", title=\"Lat\", format=\".3f\"),\n            alt.Tooltip(\"lon:Q\", title=\"Lon\", format=\".3f\"),\n        ],\n    )\n    .transform_filter(cluster_hover)\n    .project(type=\"albersUsa\")\n)\n\nclusters = (\n    alt.Chart(cluster_summary)\n    .mark_circle(opacity=0.85, stroke=PAGE_BG, strokeWidth=1.5)\n    .encode(\n        longitude=\"lon:Q\",\n        latitude=\"lat:Q\",\n        size=alt.Size(\"marker_size:Q\", scale=alt.Scale(range=[200, 2000]), legend=None),\n        color=alt.Color(\"dominant_category:N\", scale=category_colors, title=\"Category\"),\n        tooltip=[\n            alt.Tooltip(\"count:Q\", title=\"Locations\"),\n            alt.Tooltip(\"dominant_category:N\", title=\"Type\"),\n            alt.Tooltip(\"lat:Q\", title=\"Latitude\", format=\".2f\"),\n            alt.Tooltip(\"lon:Q\", title=\"Longitude\", format=\".2f\"),\n        ],\n    )\n    .add_params(cluster_hover)\n    .project(type=\"albersUsa\")\n)\n\n# Count labels — near-white text is readable on all Imprint palette marker colors\ncount_labels = (\n    alt.Chart(cluster_summary[cluster_summary[\"count\"] > 1])\n    .mark_text(fontSize=10, fontWeight=\"bold\", color=\"#FFFDF6\")\n    .encode(longitude=\"lon:Q\", latitude=\"lat:Q\", text=\"count:Q\")\n    .project(type=\"albersUsa\")\n)\n\nTITLE = \"map-marker-clustered · python · altair · anyplot.ai\"\n\nchart = (\n    (background + spider_lines + hover_points + clusters + count_labels)\n    .properties(\n        background=PAGE_BG,\n        title=alt.Title(\n            text=TITLE,\n            subtitle=\"500 US store locations · hover cluster to reveal members via spider lines (size = count)\",\n            fontSize=16,\n            subtitleFontSize=12,\n            color=INK,\n            subtitleColor=INK_SOFT,\n            anchor=\"start\",\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=10,\n        symbolSize=150,\n        orient=\"bottom-right\",\n    )\n)\n\n# Save PNG then pad to exact 3200×1800 target\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        \"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save HTML — interactive: hover cluster circles to reveal spider lines + member points\nchart.save(f\"plot-{THEME}.html\")\n"}