Clustered Marker Map — Matplotlib

A geographic map that dynamically clusters nearby markers based on the current zoom level. At lower zoom levels, clusters aggregate multiple points into a single marker displaying the count, while zooming in progressively expands clusters to reveal individual markers. This visualization is essential for efficiently displaying large geographic datasets without visual clutter, enabling users to see both the overall distribution and specific locations through interactive exploration.

Clustered Marker Map rendered with Matplotlib

Python source (Matplotlib)

""" anyplot.ai
map-marker-clustered: Clustered Marker Map
Library: matplotlib 3.10.9 | Python 3.13.13
Quality: 90/100 | Updated: 2026-05-23
"""

import os

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import FancyArrowPatch, Rectangle


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Geographic context colors (theme-adaptive)
OCEAN_BG = "#C4D9E8" if THEME == "light" else "#192633"
LAND_BG = "#EDE8D4" if THEME == "light" else "#2A2820"
BORDER_COL = "#AAAAAA" if THEME == "light" else "#555550"

# anyplot categorical palette (positions 1-3)
cat_names = ["Retail", "Grocery", "Electronics"]
cat_colors = {"Retail": "#009E73", "Grocery": "#C475FD", "Electronics": "#AE3030"}

# Data: European store locations clustered by city
np.random.seed(42)
city_centers = [
    (48.8566, 2.3522),  # Paris
    (51.5074, -0.1278),  # London
    (52.5200, 13.4050),  # Berlin
    (41.9028, 12.4964),  # Rome
    (40.4168, -3.7038),  # Madrid
    (48.2082, 16.3738),  # Vienna
    (50.0755, 14.4378),  # Prague
    (52.3676, 4.9041),  # Amsterdam
]
n_points_per_city = [45, 50, 35, 40, 30, 25, 20, 35]

lats, lons, categories = [], [], []
for (lat, lon), n_points in zip(city_centers, n_points_per_city, strict=True):
    lats.extend(np.random.normal(lat, 0.8, n_points))
    lons.extend(np.random.normal(lon, 1.2, n_points))
    categories.extend(np.random.choice(cat_names, n_points))

lats = np.array(lats)
lons = np.array(lons)
categories = np.array(categories)

# Grid-based clustering (simulates zoom-level clustering)
grid_size_lat = 3.0
grid_size_lon = 4.0
lat_bins = np.floor((lats - 36) / grid_size_lat).astype(int)
lon_bins = np.floor((lons + 12) / grid_size_lon).astype(int)
cell_ids = lat_bins * 100 + lon_bins

cluster_centers, cluster_sizes, cluster_dominant_cat = [], [], []
for cell_id in np.unique(cell_ids):
    mask = cell_ids == cell_id
    cluster_cats = categories[mask]
    unique_cats, counts = np.unique(cluster_cats, return_counts=True)
    cluster_centers.append((np.mean(lats[mask]), np.mean(lons[mask])))
    cluster_sizes.append(int(np.sum(mask)))
    cluster_dominant_cat.append(unique_cats[np.argmax(counts)])

cluster_centers = np.array(cluster_centers)
cluster_sizes = np.array(cluster_sizes)
cluster_dominant_cat = np.array(cluster_dominant_cat)

# Plot
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(OCEAN_BG)

# Simplified European land polygon
europe_coast_lon = [
    -10,
    -9,
    -9.5,
    -8,
    -5,
    -2,
    0,
    2,
    3,
    5,
    7,
    9,
    10,
    12,
    13,
    15,
    16,
    18,
    20,
    22,
    22,
    20,
    18,
    15,
    12,
    10,
    8,
    5,
    3,
    0,
    -2,
    -5,
    -8,
    -10,
    -10,
]
europe_coast_lat = [
    36,
    37,
    40,
    42,
    43,
    44,
    46,
    47,
    50,
    52,
    54,
    55,
    54,
    52,
    50,
    48,
    46,
    44,
    42,
    40,
    56,
    56,
    55,
    54,
    55,
    55,
    54,
    52,
    50,
    51,
    52,
    48,
    44,
    40,
    36,
]
ax.fill(europe_coast_lon, europe_coast_lat, color=LAND_BG, alpha=0.9, zorder=1)

# British Isles — Great Britain and Ireland (fills the map's western extent)
gb_lon = [-5.7, 1.8, 1.5, 0.0, -2.0, -4.0, -5.5, -5.0, -5.5, -5.7]
gb_lat = [50.0, 51.2, 53.0, 54.5, 55.0, 55.8, 55.0, 54.0, 52.0, 50.0]
ax.fill(gb_lon, gb_lat, color=LAND_BG, alpha=0.9, zorder=1)

ie_lon = [-10.5, -8.0, -6.0, -6.0, -6.5, -7.5, -10.0, -10.5]
ie_lat = [51.5, 51.5, 52.0, 53.0, 54.5, 55.0, 54.5, 51.5]
ax.fill(ie_lon, ie_lat, color=LAND_BG, alpha=0.9, zorder=1)

# Country boundary lines
ax.plot([-2, 3], [42.5, 42.5], color=BORDER_COL, linewidth=0.5, alpha=0.4, zorder=2)
ax.plot([6, 8, 8], [49, 49, 47], color=BORDER_COL, linewidth=0.5, alpha=0.4, zorder=2)
ax.plot([15, 15], [51, 54], color=BORDER_COL, linewidth=0.5, alpha=0.4, zorder=2)
ax.plot([6, 10, 14], [46, 47, 46], color=BORDER_COL, linewidth=0.5, alpha=0.4, zorder=2)

# Subtle coordinate grid
ax.grid(True, alpha=0.10, linestyle="--", color=INK, linewidth=0.5, zorder=3)

# Individual data points (semi-transparent density backdrop)
for cat in cat_names:
    mask = categories == cat
    ax.scatter(lons[mask], lats[mask], c=cat_colors[cat], alpha=0.20, s=12, edgecolors="none", zorder=4)

# Cluster markers — logarithmic size scaling prevents over-large small-count markers
for center, size, cat in zip(cluster_centers, cluster_sizes, cluster_dominant_cat, strict=True):
    lat, lon = center
    marker_size = 150 + np.log1p(size) * 80
    ax.scatter(lon, lat, s=marker_size, c=cat_colors[cat], alpha=0.88, edgecolors=PAGE_BG, linewidths=1.5, zorder=5)
    ax.annotate(str(size), (lon, lat), fontsize=7, fontweight="bold", ha="center", va="center", color="white", zorder=6)

# Data storytelling: annotate the London cluster (highest-count city) as the key insight
london_ref_lat, london_ref_lon = 51.5074, -0.1278
city_dist = np.sqrt((cluster_centers[:, 0] - london_ref_lat) ** 2 + (cluster_centers[:, 1] - london_ref_lon) ** 2)
largest_idx = np.argmin(city_dist)
lc_lon = float(cluster_centers[largest_idx][1])
lc_lat = float(cluster_centers[largest_idx][0])
ax.annotate(
    f"London hub · {cluster_sizes[largest_idx]} stores",
    xy=(lc_lon, lc_lat),
    xytext=(lc_lon + 6, lc_lat + 0.5),
    fontsize=6,
    color=INK,
    ha="left",
    arrowprops={"arrowstyle": "->", "color": INK_MUTED, "lw": 0.8, "shrinkB": 10},
    bbox={"facecolor": ELEVATED_BG, "edgecolor": INK_SOFT, "alpha": 0.85, "boxstyle": "round,pad=0.3"},
    zorder=7,
)

# Dashed zoom-box around London area — signals the inset region
london_zoom_rect = Rectangle(
    (-2.5, 49.8), 4.7, 3.7, fill=False, edgecolor=INK_SOFT, linewidth=0.7, linestyle="dashed", alpha=0.65, zorder=6
)
ax.add_patch(london_zoom_rect)

# North arrow using FancyArrowPatch (matplotlib.patches cartographic convention)
north_arrow = FancyArrowPatch(
    (20.5, 37.5), (20.5, 39.2), arrowstyle="->", color=INK_SOFT, mutation_scale=8, linewidth=1.2, zorder=7
)
ax.add_patch(north_arrow)
ax.text(20.5, 39.6, "N", fontsize=7, fontweight="bold", color=INK_SOFT, ha="center", va="bottom", zorder=7)

# Legend
legend_handles = [
    ax.scatter([], [], c=cat_colors[cat], s=60, label=cat, edgecolors=PAGE_BG, linewidths=1) for cat in cat_names
]
leg = ax.legend(
    handles=legend_handles,
    loc="upper left",
    fontsize=7,
    framealpha=0.95,
    title="Store Type",
    title_fontsize=8,
    borderpad=0.6,
    labelspacing=0.4,
)
if leg:
    leg.get_frame().set_facecolor(ELEVATED_BG)
    leg.get_frame().set_edgecolor(INK_SOFT)
    plt.setp(leg.get_texts(), color=INK_SOFT)
    leg.get_title().set_color(INK)

# Geographic reference labels
ax.annotate("Atlantic\nOcean", (-9, 46), fontsize=7, style="italic", color=INK_MUTED, ha="center", alpha=0.8, zorder=4)
ax.annotate(
    "Mediterranean Sea", (5, 37.5), fontsize=7, style="italic", color=INK_MUTED, ha="center", alpha=0.8, zorder=4
)

# Style
ax.set_xlabel("Longitude (°)", fontsize=10, color=INK)
ax.set_ylabel("Latitude (°)", fontsize=10, color=INK)
ax.set_title("map-marker-clustered · python · matplotlib · anyplot.ai", fontsize=12, fontweight="medium", color=INK)
ax.tick_params(axis="both", labelsize=8, colors=INK_SOFT)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
for s in ("left", "bottom"):
    ax.spines[s].set_color(INK_SOFT)

ax.set_xlim(-12, 22)
ax.set_ylim(36, 56)

# Inset axes: expanded view of London — demonstrates the spec's "zoom" dual-state concept
london_mask = (lats > 49.8) & (lats < 53.5) & (lons > -2.5) & (lons < 2.2)
ax_inset = ax.inset_axes([0.70, 0.58, 0.28, 0.35])
ax_inset.set_facecolor(OCEAN_BG)
ax_inset.fill(gb_lon, gb_lat, color=LAND_BG, alpha=0.9, zorder=1)
for cat in cat_names:
    m = london_mask & (categories == cat)
    if np.any(m):
        ax_inset.scatter(
            lons[m], lats[m], c=cat_colors[cat], s=16, alpha=0.80, edgecolors="white", linewidths=0.4, zorder=3
        )
ax_inset.set_xlim(-2.5, 2.2)
ax_inset.set_ylim(49.8, 53.5)
ax_inset.set_title("London → expanded", fontsize=5.5, color=INK, pad=2)
ax_inset.tick_params(axis="both", labelsize=4.5, colors=INK_MUTED)
for s in ("top", "right"):
    ax_inset.spines[s].set_visible(False)
for s in ("left", "bottom"):
    ax_inset.spines[s].set_color(INK_SOFT)
# District labels to represent individual marker context
for dname, dlon, dlat in [("Central", -0.12, 51.50), ("East End", 0.85, 51.52), ("North", -0.10, 52.30)]:
    ax_inset.text(dlon, dlat, dname, fontsize=5, color=INK_MUTED, ha="center", va="bottom", alpha=0.9, zorder=5)

# Summary annotation
ax.text(
    0.98,
    0.02,
    f"Total: {len(lats)} locations · {len(cluster_sizes)} clusters",
    transform=ax.transAxes,
    fontsize=7,
    ha="right",
    va="bottom",
    style="italic",
    color=INK_MUTED,
    bbox={"boxstyle": "round,pad=0.3", "facecolor": ELEVATED_BG, "alpha": 0.85, "edgecolor": INK_SOFT},
)

fig.subplots_adjust(left=0.09, right=0.98, top=0.93, bottom=0.11)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Part of Clustered Marker Map on anyplot.ai.

Other implementations