Skip to content

Commit 7c6355e

Browse files
feat(matplotlib): implement map-tile-background (#7753)
## Implementation: `map-tile-background` - python/matplotlib Implements the **python/matplotlib** version of `map-tile-background`. **File:** `plots/map-tile-background/implementations/python/matplotlib.py` **Parent Issue:** #3756 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26519817993)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent fe886c9 commit 7c6355e

2 files changed

Lines changed: 308 additions & 250 deletions

File tree

Lines changed: 139 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,172 +1,180 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
map-tile-background: Map with Tile Background
3-
Library: matplotlib 3.10.8 | Python 3.13.11
4-
Quality: 91/100 | Created: 2026-01-20
3+
Library: matplotlib 3.10.9 | Python 3.13.13
4+
Quality: 86/100 | Updated: 2026-05-27
55
"""
66

77
import io
88
import math
9+
import os
910
import urllib.request
1011

1112
import matplotlib.pyplot as plt
1213
import numpy as np
14+
from matplotlib.colors import LinearSegmentedColormap
1315
from PIL import Image
1416

1517

16-
# Helper functions to convert lat/lon to tile coordinates
17-
def lat_lon_to_tile(lat, lon, zoom):
18-
"""Convert latitude/longitude to tile x, y at given zoom level."""
19-
n = 2**zoom
20-
x = int((lon + 180) / 360 * n)
21-
lat_rad = math.radians(lat)
22-
y = int((1 - math.asinh(math.tan(lat_rad)) / math.pi) / 2 * n)
23-
return x, y
18+
# Theme tokens
19+
THEME = os.getenv("ANYPLOT_THEME", "light")
20+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
21+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
22+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
23+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
24+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
2425

26+
# Continuous colormap (imprint_seq: brand green → blue) for visitor density
27+
imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"])
2528

26-
def tile_to_lat_lon(x, y, zoom):
27-
"""Convert tile coordinates to latitude/longitude (NW corner)."""
28-
n = 2**zoom
29-
lon = x / n * 360 - 180
30-
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
31-
lat = math.degrees(lat_rad)
32-
return lat, lon
33-
34-
35-
def fetch_tile(x, y, zoom, source="osm"):
36-
"""Fetch a single map tile from tile server."""
37-
if source == "osm":
38-
url = f"https://tile.openstreetmap.org/{zoom}/{x}/{y}.png"
39-
40-
headers = {"User-Agent": "pyplots.ai/1.0 (https://pyplots.ai; visualization demo)"}
41-
req = urllib.request.Request(url, headers=headers)
42-
43-
with urllib.request.urlopen(req, timeout=10) as response:
44-
return Image.open(io.BytesIO(response.read()))
45-
46-
47-
def get_map_tiles(lat_min, lat_max, lon_min, lon_max, zoom=10):
48-
"""Fetch and stitch map tiles for a bounding box."""
49-
# Get tile range
50-
x_min, y_max = lat_lon_to_tile(lat_min, lon_min, zoom)
51-
x_max, y_min = lat_lon_to_tile(lat_max, lon_max, zoom)
52-
53-
# Calculate tile dimensions
54-
tiles_x = x_max - x_min + 1
55-
tiles_y = y_max - y_min + 1
56-
57-
# Create blank image
58-
tile_size = 256
59-
full_image = Image.new("RGB", (tiles_x * tile_size, tiles_y * tile_size))
60-
61-
# Fetch and stitch tiles
62-
for x in range(x_min, x_max + 1):
63-
for y in range(y_min, y_max + 1):
64-
tile = fetch_tile(x, y, zoom)
65-
pos_x = (x - x_min) * tile_size
66-
pos_y = (y - y_min) * tile_size
67-
full_image.paste(tile, (pos_x, pos_y))
68-
69-
# Calculate geographic extent of stitched image
70-
nw_lat, nw_lon = tile_to_lat_lon(x_min, y_min, zoom)
71-
se_lat, se_lon = tile_to_lat_lon(x_max + 1, y_max + 1, zoom)
72-
73-
extent = [nw_lon, se_lon, se_lat, nw_lat]
74-
return np.array(full_image), extent
75-
76-
77-
# Data: Tourist attractions in Rome with visitor counts
78-
np.random.seed(42)
79-
29+
# Data: Rome tourist attractions with daily visitor counts (realistic scale)
8030
locations = {
81-
"Colosseum": (41.8902, 12.4922, 7200),
82-
"Vatican Museums": (41.9065, 12.4536, 6800),
83-
"Trevi Fountain": (41.9009, 12.4833, 5500),
84-
"Pantheon": (41.8986, 12.4769, 4800),
85-
"Roman Forum": (41.8925, 12.4853, 4200),
86-
"St. Peter's Basilica": (41.9022, 12.4539, 6500),
87-
"Spanish Steps": (41.9060, 12.4828, 3800),
88-
"Piazza Navona": (41.8992, 12.4730, 3200),
89-
"Castel Sant'Angelo": (41.9031, 12.4663, 2800),
90-
"Villa Borghese": (41.9137, 12.4855, 2400),
91-
"Trastevere": (41.8867, 12.4692, 2100),
92-
"Campo de' Fiori": (41.8956, 12.4722, 1800),
31+
"Colosseum": (41.8902, 12.4922, 21000),
32+
"Vatican Museums": (41.9065, 12.4536, 20500),
33+
"St. Peter's Basilica": (41.9022, 12.4539, 19500),
34+
"Trevi Fountain": (41.9009, 12.4833, 18000),
35+
"Pantheon": (41.8986, 12.4769, 15000),
36+
"Roman Forum": (41.8925, 12.4853, 13000),
37+
"Spanish Steps": (41.9060, 12.4828, 12000),
38+
"Piazza Navona": (41.8992, 12.4730, 10000),
39+
"Castel Sant'Angelo": (41.9031, 12.4663, 8500),
40+
"Villa Borghese": (41.9137, 12.4855, 7000),
41+
"Trastevere": (41.8867, 12.4692, 6500),
42+
"Campo de' Fiori": (41.8956, 12.4722, 5500),
43+
}
44+
45+
# Per-attraction label offsets (directional spread to reduce crowding)
46+
label_offsets = {
47+
"Colosseum": (8, -8),
48+
"Vatican Museums": (-5, 8),
49+
"St. Peter's Basilica": (-5, -12),
50+
"Trevi Fountain": (8, 5),
51+
"Pantheon": (8, -10),
52+
"Roman Forum": (8, -8),
53+
"Spanish Steps": (8, 5),
54+
"Piazza Navona": (-5, 8),
55+
"Castel Sant'Angelo": (8, 8),
56+
"Villa Borghese": (8, 4),
57+
"Trastevere": (-5, -10),
58+
"Campo de' Fiori": (-5, 8),
9359
}
9460

95-
lats = np.array([loc[0] for loc in locations.values()])
96-
lons = np.array([loc[1] for loc in locations.values()])
97-
visitors = np.array([loc[2] for loc in locations.values()]) # Daily visitors (thousands)
9861
names = list(locations.keys())
62+
lats = np.array([v[0] for v in locations.values()])
63+
lons = np.array([v[1] for v in locations.values()])
64+
visitors = np.array([v[2] for v in locations.values()])
9965

100-
# Calculate bounds with padding
101-
lat_margin = 0.015
102-
lon_margin = 0.025
103-
lat_min, lat_max = lats.min() - lat_margin, lats.max() + lat_margin
104-
lon_min, lon_max = lons.min() - lon_margin, lons.max() + lon_margin
66+
# Bounding box with padding
67+
lat_min = lats.min() - 0.015
68+
lat_max = lats.max() + 0.015
69+
lon_min = lons.min() - 0.025
70+
lon_max = lons.max() + 0.025
10571

106-
# Fetch map tiles
72+
# Theme-adaptive tile provider
10773
zoom = 14
108-
map_img, extent = get_map_tiles(lat_min, lat_max, lon_min, lon_max, zoom)
109-
110-
# Create figure
111-
fig, ax = plt.subplots(figsize=(16, 9))
112-
113-
# Display map background
114-
ax.imshow(map_img, extent=extent, aspect="auto", zorder=0)
115-
116-
# Scale point sizes based on visitor counts
117-
min_size = 150
118-
max_size = 800
119-
sizes = min_size + (visitors - visitors.min()) / (visitors.max() - visitors.min()) * (max_size - min_size)
74+
n_tiles = 2**zoom
75+
if THEME == "light":
76+
tile_url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
77+
attribution = "© OpenStreetMap contributors"
78+
else:
79+
tile_url = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
80+
attribution = "© OpenStreetMap contributors, © CARTO"
81+
82+
# Tile index range for the bounding box
83+
tx_min = int((lon_min + 180) / 360 * n_tiles)
84+
tx_max = int((lon_max + 180) / 360 * n_tiles)
85+
ty_min = int((1 - math.asinh(math.tan(math.radians(lat_max))) / math.pi) / 2 * n_tiles)
86+
ty_max = int((1 - math.asinh(math.tan(math.radians(lat_min))) / math.pi) / 2 * n_tiles)
87+
88+
# Fetch and stitch tiles into a single image
89+
tile_size = 256
90+
stitched = Image.new("RGB", ((tx_max - tx_min + 1) * tile_size, (ty_max - ty_min + 1) * tile_size))
91+
ua = {"User-Agent": "anyplot.ai/1.0 (https://anyplot.ai; visualization demo)"}
92+
for tx in range(tx_min, tx_max + 1):
93+
for ty in range(ty_min, ty_max + 1):
94+
req = urllib.request.Request(tile_url.format(z=zoom, x=tx, y=ty), headers=ua)
95+
with urllib.request.urlopen(req, timeout=10) as resp:
96+
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
97+
stitched.paste(tile, ((tx - tx_min) * tile_size, (ty - ty_min) * tile_size))
98+
99+
# Geographic extent of the stitched image [left, right, bottom, top]
100+
nw_lon = tx_min / n_tiles * 360 - 180
101+
nw_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * ty_min / n_tiles))))
102+
se_lon = (tx_max + 1) / n_tiles * 360 - 180
103+
se_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (ty_max + 1) / n_tiles))))
104+
105+
# Plot
106+
title = "Rome Tourist Attractions · map-tile-background · python · matplotlib · anyplot.ai"
107+
title_fontsize = max(8, round(12 * 67 / len(title)))
108+
109+
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
110+
ax.set_facecolor(PAGE_BG)
111+
112+
ax.imshow(np.array(stitched), extent=[nw_lon, se_lon, se_lat, nw_lat], aspect="auto", zorder=0)
113+
114+
# Scatter: size and color both encode daily visitor count
115+
sizes = 60 + (visitors - visitors.min()) / (visitors.max() - visitors.min()) * 290
120116

121-
# Plot data points with Python colors
122117
scatter = ax.scatter(
123-
lons, lats, c=visitors, s=sizes, cmap="YlOrRd", alpha=0.85, edgecolors="white", linewidth=2.5, zorder=5
118+
lons,
119+
lats,
120+
c=visitors,
121+
s=sizes,
122+
cmap=imprint_seq,
123+
vmin=visitors.min(),
124+
vmax=visitors.max(),
125+
alpha=0.9,
126+
edgecolors=PAGE_BG,
127+
linewidth=1.5,
128+
zorder=5,
124129
)
125130

126-
# Add colorbar
131+
# Attraction name labels with per-attraction directional offsets
132+
for name, lon, lat in zip(names, lons, lats, strict=False):
133+
dx, dy = label_offsets.get(name, (6, 4))
134+
ax.annotate(
135+
name,
136+
(lon, lat),
137+
xytext=(dx, dy),
138+
textcoords="offset points",
139+
fontsize=6,
140+
color=INK,
141+
bbox={"boxstyle": "round,pad=0.15", "facecolor": ELEVATED_BG, "edgecolor": "none", "alpha": 0.75},
142+
zorder=8,
143+
)
144+
145+
# Colorbar
127146
cbar = plt.colorbar(scatter, ax=ax, shrink=0.75, pad=0.02)
128-
cbar.set_label("Daily Visitors (thousands)", fontsize=16)
129-
cbar.ax.tick_params(labelsize=14)
147+
cbar.set_label("Daily Visitors", fontsize=8, color=INK_SOFT)
148+
cbar.ax.tick_params(labelsize=7, colors=INK_SOFT)
149+
cbar.outline.set_edgecolor(INK_SOFT)
130150

131-
# Set axis limits to data extent
151+
# Axis limits and labels
132152
ax.set_xlim(lon_min, lon_max)
133153
ax.set_ylim(lat_min, lat_max)
134-
135-
# Labels and title
136-
ax.set_xlabel("Longitude", fontsize=20)
137-
ax.set_ylabel("Latitude", fontsize=20)
138-
ax.set_title("map-tile-background · matplotlib · pyplots.ai", fontsize=24, pad=15)
139-
ax.tick_params(axis="both", labelsize=14)
140-
141-
# Format tick labels as coordinates
154+
ax.set_xlabel("Longitude", fontsize=10, color=INK)
155+
ax.set_ylabel("Latitude", fontsize=10, color=INK)
156+
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK)
157+
ax.tick_params(axis="both", labelsize=8, colors=INK_SOFT)
142158
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"{x:.3f}°E"))
143159
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, p: f"{y:.3f}°N"))
144160

145-
# Add OpenStreetMap attribution (required by license)
161+
for spine in ("left", "bottom"):
162+
ax.spines[spine].set_color(INK_SOFT)
163+
ax.spines["top"].set_visible(False)
164+
ax.spines["right"].set_visible(False)
165+
166+
# Tile attribution (required by provider license)
146167
ax.text(
147168
0.99,
148169
0.01,
149-
"© OpenStreetMap contributors",
170+
attribution,
150171
transform=ax.transAxes,
151-
fontsize=10,
172+
fontsize=6,
152173
ha="right",
153174
va="bottom",
154-
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8, edgecolor="#cccccc"),
155-
zorder=10,
156-
)
157-
158-
# Add context annotation
159-
ax.text(
160-
0.01,
161-
0.99,
162-
"Rome Tourist Attractions\nMarker size = visitor volume",
163-
transform=ax.transAxes,
164-
fontsize=12,
165-
ha="left",
166-
va="top",
167-
bbox=dict(boxstyle="round,pad=0.4", facecolor="white", alpha=0.85, edgecolor="#cccccc"),
175+
color=INK_MUTED,
176+
bbox={"boxstyle": "round,pad=0.3", "facecolor": ELEVATED_BG, "edgecolor": INK_SOFT, "alpha": 0.85},
168177
zorder=10,
169178
)
170179

171-
plt.tight_layout()
172-
plt.savefig("plot.png", dpi=300, bbox_inches="tight")
180+
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

0 commit comments

Comments
 (0)