|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import io |
8 | 8 | import math |
| 9 | +import os |
9 | 10 | import urllib.request |
10 | 11 |
|
11 | 12 | import matplotlib.pyplot as plt |
12 | 13 | import numpy as np |
| 14 | +from matplotlib.colors import LinearSegmentedColormap |
13 | 15 | from PIL import Image |
14 | 16 |
|
15 | 17 |
|
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" |
24 | 25 |
|
| 26 | +# Continuous colormap (imprint_seq: brand green → blue) for visitor density |
| 27 | +imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"]) |
25 | 28 |
|
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) |
80 | 30 | 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), |
93 | 59 | } |
94 | 60 |
|
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) |
98 | 61 | 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()]) |
99 | 65 |
|
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 |
105 | 71 |
|
106 | | -# Fetch map tiles |
| 72 | +# Theme-adaptive tile provider |
107 | 73 | 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 |
120 | 116 |
|
121 | | -# Plot data points with Python colors |
122 | 117 | 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, |
124 | 129 | ) |
125 | 130 |
|
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 |
127 | 146 | 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) |
130 | 150 |
|
131 | | -# Set axis limits to data extent |
| 151 | +# Axis limits and labels |
132 | 152 | ax.set_xlim(lon_min, lon_max) |
133 | 153 | 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) |
142 | 158 | ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"{x:.3f}°E")) |
143 | 159 | ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, p: f"{y:.3f}°N")) |
144 | 160 |
|
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) |
146 | 167 | ax.text( |
147 | 168 | 0.99, |
148 | 169 | 0.01, |
149 | | - "© OpenStreetMap contributors", |
| 170 | + attribution, |
150 | 171 | transform=ax.transAxes, |
151 | | - fontsize=10, |
| 172 | + fontsize=6, |
152 | 173 | ha="right", |
153 | 174 | 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}, |
168 | 177 | zorder=10, |
169 | 178 | ) |
170 | 179 |
|
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