forked from originalankur/maptoposter
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcreate_map_poster.py
More file actions
executable file
·1051 lines (889 loc) · 32.5 KB
/
create_map_poster.py
File metadata and controls
executable file
·1051 lines (889 loc) · 32.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
City Map Poster Generator
This module generates beautiful, minimalist map posters for any city in the world.
It fetches OpenStreetMap data using OSMnx, applies customizable themes, and creates
high-quality poster-ready images with roads, water features, and parks.
"""
import argparse
import asyncio
import json
import os
import pickle
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import cast
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import osmnx as ox
from geopandas import GeoDataFrame
from geopy.geocoders import Nominatim
from lat_lon_parser import parse
from matplotlib.font_manager import FontProperties
from networkx import MultiDiGraph
from shapely.geometry import Point
from tqdm import tqdm
from font_management import load_fonts
class CacheError(Exception):
"""Raised when a cache operation fails."""
CACHE_DIR_PATH = os.environ.get("CACHE_DIR", "cache")
CACHE_DIR = Path(CACHE_DIR_PATH)
CACHE_DIR.mkdir(exist_ok=True)
THEMES_DIR = "themes"
FONTS_DIR = "fonts"
POSTERS_DIR = "posters"
FILE_ENCODING = "utf-8"
FONTS = load_fonts()
def _cache_path(key: str) -> str:
"""
Generate a safe cache file path from a cache key.
Args:
key: Cache key identifier
Returns:
Path to cache file with .pkl extension
"""
safe = key.replace(os.sep, "_")
return os.path.join(CACHE_DIR, f"{safe}.pkl")
def cache_get(key: str):
"""
Retrieve a cached object by key.
Args:
key: Cache key identifier
Returns:
Cached object if found, None otherwise
Raises:
CacheError: If cache read operation fails
"""
try:
path = _cache_path(key)
if not os.path.exists(path):
return None
with open(path, "rb") as f:
return pickle.load(f)
except Exception as e:
raise CacheError(f"Cache read failed: {e}") from e
def cache_set(key: str, value):
"""
Store an object in the cache.
Args:
key: Cache key identifier
value: Object to cache (must be picklable)
Raises:
CacheError: If cache write operation fails
"""
try:
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR)
path = _cache_path(key)
with open(path, "wb") as f:
pickle.dump(value, f, protocol=pickle.HIGHEST_PROTOCOL)
except Exception as e:
raise CacheError(f"Cache write failed: {e}") from e
# Font loading now handled by font_management.py module
def is_latin_script(text):
"""
Check if text is primarily Latin script.
Used to determine if letter-spacing should be applied to city names.
:param text: Text to analyze
:return: True if text is primarily Latin script, False otherwise
"""
if not text:
return True
latin_count = 0
total_alpha = 0
for char in text:
if char.isalpha():
total_alpha += 1
# Latin Unicode ranges:
# - Basic Latin: U+0000 to U+007F
# - Latin-1 Supplement: U+0080 to U+00FF
# - Latin Extended-A: U+0100 to U+017F
# - Latin Extended-B: U+0180 to U+024F
if ord(char) < 0x250:
latin_count += 1
# If no alphabetic characters, default to Latin (numbers, symbols, etc.)
if total_alpha == 0:
return True
# Consider it Latin if >80% of alphabetic characters are Latin
return (latin_count / total_alpha) > 0.8
def generate_output_filename(city, theme_name, output_format):
"""
Generate unique output filename with city, theme, and datetime.
"""
if not os.path.exists(POSTERS_DIR):
os.makedirs(POSTERS_DIR)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
city_slug = city.lower().replace(" ", "_")
ext = output_format.lower()
filename = f"{city_slug}_{theme_name}_{timestamp}.{ext}"
return os.path.join(POSTERS_DIR, filename)
def get_available_themes():
"""
Scans the themes directory and returns a list of available theme names.
"""
if not os.path.exists(THEMES_DIR):
os.makedirs(THEMES_DIR)
return []
themes = []
for file in sorted(os.listdir(THEMES_DIR)):
if file.endswith(".json"):
theme_name = file[:-5] # Remove .json extension
themes.append(theme_name)
return themes
def load_theme(theme_name="terracotta"):
"""
Load theme from JSON file in themes directory.
"""
theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json")
if not os.path.exists(theme_file):
print(f"⚠ Theme file '{theme_file}' not found. Using default terracotta theme.")
# Fallback to embedded terracotta theme
return {
"name": "Terracotta",
"description": "Mediterranean warmth - burnt orange and clay tones on cream",
"bg": "#F5EDE4",
"text": "#8B4513",
"gradient_color": "#F5EDE4",
"water": "#A8C4C4",
"parks": "#E8E0D0",
"road_motorway": "#A0522D",
"road_primary": "#B8653A",
"road_secondary": "#C9846A",
"road_tertiary": "#D9A08A",
"road_residential": "#E5C4B0",
"road_default": "#D9A08A",
}
with open(theme_file, "r", encoding=FILE_ENCODING) as f:
theme = json.load(f)
print(f"✓ Loaded theme: {theme.get('name', theme_name)}")
if "description" in theme:
print(f" {theme['description']}")
return theme
# Load theme (can be changed via command line or input)
THEME = dict[str, str]() # Will be loaded later
def create_gradient_fade(ax, color, location="bottom", zorder=10):
"""
Creates a fade effect at the top or bottom of the map.
"""
vals = np.linspace(0, 1, 256).reshape(-1, 1)
gradient = np.hstack((vals, vals))
rgb = mcolors.to_rgb(color)
my_colors = np.zeros((256, 4))
my_colors[:, 0] = rgb[0]
my_colors[:, 1] = rgb[1]
my_colors[:, 2] = rgb[2]
if location == "bottom":
my_colors[:, 3] = np.linspace(1, 0, 256)
extent_y_start = 0
extent_y_end = 0.25
else:
my_colors[:, 3] = np.linspace(0, 1, 256)
extent_y_start = 0.75
extent_y_end = 1.0
custom_cmap = mcolors.ListedColormap(my_colors)
xlim = ax.get_xlim()
ylim = ax.get_ylim()
y_range = ylim[1] - ylim[0]
y_bottom = ylim[0] + y_range * extent_y_start
y_top = ylim[0] + y_range * extent_y_end
ax.imshow(
gradient,
extent=[xlim[0], xlim[1], y_bottom, y_top],
aspect="auto",
cmap=custom_cmap,
zorder=zorder,
origin="lower",
)
def get_edge_colors_by_type(g):
"""
Assigns colors to edges based on road type hierarchy.
Returns a list of colors corresponding to each edge in the graph.
"""
edge_colors = []
for _u, _v, data in g.edges(data=True):
# Get the highway type (can be a list or string)
highway = data.get('highway', 'unclassified')
# Handle list of highway types (take the first one)
if isinstance(highway, list):
highway = highway[0] if highway else 'unclassified'
# Assign color based on road type
if highway in ["motorway", "motorway_link"]:
color = THEME["road_motorway"]
elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
color = THEME["road_primary"]
elif highway in ["secondary", "secondary_link"]:
color = THEME["road_secondary"]
elif highway in ["tertiary", "tertiary_link"]:
color = THEME["road_tertiary"]
elif highway in ["residential", "living_street", "unclassified"]:
color = THEME["road_residential"]
else:
color = THEME['road_default']
edge_colors.append(color)
return edge_colors
def get_edge_widths_by_type(g):
"""
Assigns line widths to edges based on road type.
Major roads get thicker lines.
"""
edge_widths = []
for _u, _v, data in g.edges(data=True):
highway = data.get('highway', 'unclassified')
if isinstance(highway, list):
highway = highway[0] if highway else 'unclassified'
# Assign width based on road importance
if highway in ["motorway", "motorway_link"]:
width = 1.2
elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
width = 1.0
elif highway in ["secondary", "secondary_link"]:
width = 0.8
elif highway in ["tertiary", "tertiary_link"]:
width = 0.6
else:
width = 0.4
edge_widths.append(width)
return edge_widths
def get_coordinates(city, country):
"""
Fetches coordinates for a given city and country using geopy.
Includes rate limiting to be respectful to the geocoding service.
"""
coords = f"coords_{city.lower()}_{country.lower()}"
cached = cache_get(coords)
if cached:
print(f"✓ Using cached coordinates for {city}, {country}")
return cached
print("Looking up coordinates...")
geolocator = Nominatim(user_agent="city_map_poster", timeout=10)
# Add a small delay to respect Nominatim's usage policy
time.sleep(1)
try:
location = geolocator.geocode(f"{city}, {country}")
except Exception as e:
raise ValueError(f"Geocoding failed for {city}, {country}: {e}") from e
# If geocode returned a coroutine in some environments, run it to get the result.
if asyncio.iscoroutine(location):
try:
location = asyncio.run(location)
except RuntimeError as exc:
# If an event loop is already running, try using it to complete the coroutine.
loop = asyncio.get_event_loop()
if loop.is_running():
# Running event loop in the same thread; raise a clear error.
raise RuntimeError(
"Geocoder returned a coroutine while an event loop is already running. "
"Run this script in a synchronous environment."
) from exc
location = loop.run_until_complete(location)
if location:
# Use getattr to safely access address (helps static analyzers)
addr = getattr(location, "address", None)
if addr:
print(f"✓ Found: {addr}")
else:
print("✓ Found location (address not available)")
print(f"✓ Coordinates: {location.latitude}, {location.longitude}")
try:
cache_set(coords, (location.latitude, location.longitude))
except CacheError as e:
print(e)
return (location.latitude, location.longitude)
raise ValueError(f"Could not find coordinates for {city}, {country}")
def get_crop_limits(g_proj, center_lat_lon, fig, dist):
"""
Crop inward to preserve aspect ratio while guaranteeing
full coverage of the requested radius.
"""
lat, lon = center_lat_lon
# Project center point into graph CRS
center = (
ox.projection.project_geometry(
Point(lon, lat),
crs="EPSG:4326",
to_crs=g_proj.graph["crs"]
)[0]
)
center_x, center_y = center.x, center.y
fig_width, fig_height = fig.get_size_inches()
aspect = fig_width / fig_height
# Start from the *requested* radius
half_x = dist
half_y = dist
# Cut inward to match aspect
if aspect > 1: # landscape → reduce height
half_y = half_x / aspect
else: # portrait → reduce width
half_x = half_y * aspect
return (
(center_x - half_x, center_x + half_x),
(center_y - half_y, center_y + half_y),
)
def fetch_graph(point, dist) -> MultiDiGraph | None:
"""
Fetch street network graph from OpenStreetMap.
Uses caching to avoid redundant downloads. Fetches all network types
within the specified distance from the center point.
Args:
point: (latitude, longitude) tuple for center point
dist: Distance in meters from center point
Returns:
MultiDiGraph of street network, or None if fetch fails
"""
lat, lon = point
graph = f"graph_{lat}_{lon}_{dist}"
cached = cache_get(graph)
if cached is not None:
print("✓ Using cached street network")
return cast(MultiDiGraph, cached)
try:
g = ox.graph_from_point(point, dist=dist, dist_type='bbox', network_type='all', truncate_by_edge=True)
# Rate limit between requests
time.sleep(0.5)
try:
cache_set(graph, g)
except CacheError as e:
print(e)
return g
except Exception as e:
print(f"OSMnx error while fetching graph: {e}")
return None
def fetch_features(point, dist, tags, name) -> GeoDataFrame | None:
"""
Fetch geographic features (water, parks, etc.) from OpenStreetMap.
Uses caching to avoid redundant downloads. Fetches features matching
the specified OSM tags within distance from center point.
Args:
point: (latitude, longitude) tuple for center point
dist: Distance in meters from center point
tags: Dictionary of OSM tags to filter features
name: Name for this feature type (for caching and logging)
Returns:
GeoDataFrame of features, or None if fetch fails
"""
lat, lon = point
tag_str = "_".join(tags.keys())
features = f"{name}_{lat}_{lon}_{dist}_{tag_str}"
cached = cache_get(features)
if cached is not None:
print(f"✓ Using cached {name}")
return cast(GeoDataFrame, cached)
try:
data = ox.features_from_point(point, tags=tags, dist=dist)
# Rate limit between requests
time.sleep(0.3)
try:
cache_set(features, data)
except CacheError as e:
print(e)
return data
except Exception as e:
print(f"OSMnx error while fetching features: {e}")
return None
def create_poster(
city,
country,
point,
dist,
output_file,
output_format,
width=12,
height=16,
country_label=None,
name_label=None,
display_city=None,
display_country=None,
fonts=None,
):
"""
Generate a complete map poster with roads, water, parks, and typography.
Creates a high-quality poster by fetching OSM data, rendering map layers,
applying the current theme, and adding text labels with coordinates.
Args:
city: City name for display on poster
country: Country name for display on poster
point: (latitude, longitude) tuple for map center
dist: Map radius in meters
output_file: Path where poster will be saved
output_format: File format ('png', 'svg', or 'pdf')
width: Poster width in inches (default: 12)
height: Poster height in inches (default: 16)
country_label: Optional override for country text on poster
_name_label: Optional override for city name (unused, reserved for future use)
Raises:
RuntimeError: If street network data cannot be retrieved
"""
# Handle display names for i18n support
# Priority: display_city/display_country > name_label/country_label > city/country
display_city = display_city or name_label or city
display_country = display_country or country_label or country
print(f"\nGenerating map for {city}, {country}...")
# Progress bar for data fetching
with tqdm(
total=3,
desc="Fetching map data",
unit="step",
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}",
) as pbar:
# 1. Fetch Street Network
pbar.set_description("Downloading street network")
compensated_dist = dist * (max(height, width) / min(height, width)) / 4 # To compensate for viewport crop
g = fetch_graph(point, compensated_dist)
if g is None:
raise RuntimeError("Failed to retrieve street network data.")
pbar.update(1)
# 2. Fetch Water Features
pbar.set_description("Downloading water features")
water = fetch_features(
point,
compensated_dist,
tags={"natural": ["water", "bay", "strait"], "waterway": "riverbank"},
name="water",
)
pbar.update(1)
# 3. Fetch Parks
pbar.set_description("Downloading parks/green spaces")
parks = fetch_features(
point,
compensated_dist,
tags={"leisure": "park", "landuse": "grass"},
name="parks",
)
pbar.update(1)
print("✓ All data retrieved successfully!")
# 2. Setup Plot
print("Rendering map...")
fig, ax = plt.subplots(figsize=(width, height), facecolor=THEME["bg"])
ax.set_facecolor(THEME["bg"])
ax.set_position((0.0, 0.0, 1.0, 1.0))
# Project graph to a metric CRS so distances and aspect are linear (meters)
g_proj = ox.project_graph(g)
# 3. Plot Layers
# Layer 1: Polygons (filter to only plot polygon/multipolygon geometries, not points)
if water is not None and not water.empty:
# Filter to only polygon/multipolygon geometries to avoid point features showing as dots
water_polys = water[water.geometry.type.isin(["Polygon", "MultiPolygon"])]
if not water_polys.empty:
# Project water features in the same CRS as the graph
try:
water_polys = ox.projection.project_gdf(water_polys)
except Exception:
water_polys = water_polys.to_crs(g_proj.graph['crs'])
water_polys.plot(ax=ax, facecolor=THEME['water'], edgecolor='none', zorder=0.5)
if parks is not None and not parks.empty:
# Filter to only polygon/multipolygon geometries to avoid point features showing as dots
parks_polys = parks[parks.geometry.type.isin(["Polygon", "MultiPolygon"])]
if not parks_polys.empty:
# Project park features in the same CRS as the graph
try:
parks_polys = ox.projection.project_gdf(parks_polys)
except Exception:
parks_polys = parks_polys.to_crs(g_proj.graph['crs'])
parks_polys.plot(ax=ax, facecolor=THEME['parks'], edgecolor='none', zorder=0.8)
# Layer 2: Roads with hierarchy coloring
print("Applying road hierarchy colors...")
edge_colors = get_edge_colors_by_type(g_proj)
edge_widths = get_edge_widths_by_type(g_proj)
# Determine cropping limits to maintain the poster aspect ratio
crop_xlim, crop_ylim = get_crop_limits(g_proj, point, fig, compensated_dist)
# Plot the projected graph and then apply the cropped limits
ox.plot_graph(
g_proj, ax=ax, bgcolor=THEME['bg'],
node_size=0,
edge_color=edge_colors,
edge_linewidth=edge_widths,
show=False,
close=False,
)
ax.set_aspect("equal", adjustable="box")
ax.set_xlim(crop_xlim)
ax.set_ylim(crop_ylim)
# Layer 3: Gradients (Top and Bottom)
create_gradient_fade(ax, THEME['gradient_color'], location='bottom', zorder=10)
create_gradient_fade(ax, THEME['gradient_color'], location='top', zorder=10)
# Calculate scale factor based on smaller dimension (reference 12 inches)
# This ensures text scales properly for both portrait and landscape orientations
scale_factor = min(height, width) / 12.0
# Base font sizes (at 12 inches width)
base_main = 60
base_sub = 22
base_coords = 14
base_attr = 8
# 4. Typography - use custom fonts if provided, otherwise use default FONTS
active_fonts = fonts or FONTS
if active_fonts:
# font_main is calculated dynamically later based on length
font_sub = FontProperties(
fname=active_fonts["light"], size=base_sub * scale_factor
)
font_coords = FontProperties(
fname=active_fonts["regular"], size=base_coords * scale_factor
)
font_attr = FontProperties(
fname=active_fonts["light"], size=base_attr * scale_factor
)
else:
# Fallback to system fonts
font_sub = FontProperties(
family="monospace", weight="normal", size=base_sub * scale_factor
)
font_coords = FontProperties(
family="monospace", size=base_coords * scale_factor
)
font_attr = FontProperties(family="monospace", size=base_attr * scale_factor)
# Format city name based on script type
# Latin scripts: apply uppercase and letter spacing for aesthetic
# Non-Latin scripts (CJK, Thai, Arabic, etc.): no spacing, preserve case structure
if is_latin_script(display_city):
# Latin script: uppercase with letter spacing (e.g., "P A R I S")
spaced_city = " ".join(list(display_city.upper()))
else:
# Non-Latin script: no spacing, no forced uppercase
# For scripts like Arabic, Thai, Japanese, etc.
spaced_city = display_city
# Dynamically adjust font size based on city name length to prevent truncation
# We use the already scaled "main" font size as the starting point.
base_adjusted_main = base_main * scale_factor
city_char_count = len(display_city)
# Heuristic: If length is > 10, start reducing.
if city_char_count > 10:
length_factor = 10 / city_char_count
adjusted_font_size = max(base_adjusted_main * length_factor, 10 * scale_factor)
else:
adjusted_font_size = base_adjusted_main
if active_fonts:
font_main_adjusted = FontProperties(
fname=active_fonts["bold"], size=adjusted_font_size
)
else:
font_main_adjusted = FontProperties(
family="monospace", weight="bold", size=adjusted_font_size
)
# --- BOTTOM TEXT ---
ax.text(
0.5,
0.14,
spaced_city,
transform=ax.transAxes,
color=THEME["text"],
ha="center",
fontproperties=font_main_adjusted,
zorder=11,
)
ax.text(
0.5,
0.10,
display_country.upper(),
transform=ax.transAxes,
color=THEME["text"],
ha="center",
fontproperties=font_sub,
zorder=11,
)
lat, lon = point
coords = (
f"{lat:.4f}° N / {lon:.4f}° E"
if lat >= 0
else f"{abs(lat):.4f}° S / {lon:.4f}° E"
)
if lon < 0:
coords = coords.replace("E", "W")
ax.text(
0.5,
0.07,
coords,
transform=ax.transAxes,
color=THEME["text"],
alpha=0.7,
ha="center",
fontproperties=font_coords,
zorder=11,
)
ax.plot(
[0.4, 0.6],
[0.125, 0.125],
transform=ax.transAxes,
color=THEME["text"],
linewidth=1 * scale_factor,
zorder=11,
)
# --- ATTRIBUTION (bottom right) ---
if FONTS:
font_attr = FontProperties(fname=FONTS["light"], size=8)
else:
font_attr = FontProperties(family="monospace", size=8)
ax.text(
0.98,
0.02,
"© OpenStreetMap contributors",
transform=ax.transAxes,
color=THEME["text"],
alpha=0.5,
ha="right",
va="bottom",
fontproperties=font_attr,
zorder=11,
)
# 5. Save
print(f"Saving to {output_file}...")
fmt = output_format.lower()
save_kwargs = dict(
facecolor=THEME["bg"],
bbox_inches="tight",
pad_inches=0.05,
)
# DPI matters mainly for raster formats
if fmt == "png":
save_kwargs["dpi"] = 300
plt.savefig(output_file, format=fmt, **save_kwargs)
plt.close()
print(f"✓ Done! Poster saved as {output_file}")
def print_examples():
"""Print usage examples."""
print("""
City Map Poster Generator
=========================
Usage:
python create_map_poster.py --city <city> --country <country> [options]
Examples:
# Iconic grid patterns
python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid
python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid
# Waterfront & canals
python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network
python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals
python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline
# Radial patterns
python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards
python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads
# Organic old cities
python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets
python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze
python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient street layout
# Coastal cities
python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid
python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city
python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula
# River cities
python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves
python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split
# List themes
python create_map_poster.py --list-themes
Options:
--city, -c City name (required)
--country, -C Country name (required)
--country-label Override country text displayed on poster
--theme, -t Theme name (default: terracotta)
--all-themes Generate posters for all themes
--distance, -d Map radius in meters (default: 18000)
--list-themes List all available themes
Distance guide:
4000-6000m Small/dense cities (Venice, Amsterdam old center)
8000-12000m Medium cities, focused downtown (Paris, Barcelona)
15000-20000m Large metros, full city view (Tokyo, Mumbai)
Available themes can be found in the 'themes/' directory.
Generated posters are saved to 'posters/' directory.
""")
def list_themes():
"""List all available themes with descriptions."""
available_themes = get_available_themes()
if not available_themes:
print("No themes found in 'themes/' directory.")
return
print("\nAvailable Themes:")
print("-" * 60)
for theme_name in available_themes:
theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json")
try:
with open(theme_path, "r", encoding=FILE_ENCODING) as f:
theme_data = json.load(f)
display_name = theme_data.get('name', theme_name)
description = theme_data.get('description', '')
except (OSError, json.JSONDecodeError):
display_name = theme_name
description = ""
print(f" {theme_name}")
print(f" {display_name}")
if description:
print(f" {description}")
print()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Generate beautiful map posters for any city",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python create_map_poster.py --city "New York" --country "USA"
python create_map_poster.py --city "New York" --country "USA" -l 40.776676 -73.971321 --theme neon_cyberpunk
python create_map_poster.py --city Tokyo --country Japan --theme midnight_blue
python create_map_poster.py --city Paris --country France --theme noir --distance 15000
python create_map_poster.py --list-themes
""",
)
parser.add_argument("--city", "-c", type=str, help="City name")
parser.add_argument("--country", "-C", type=str, help="Country name")
parser.add_argument(
"--latitude",
"-lat",
dest="latitude",
type=str,
help="Override latitude center point",
)
parser.add_argument(
"--longitude",
"-long",
dest="longitude",
type=str,
help="Override longitude center point",
)
parser.add_argument(
"--country-label",
dest="country_label",
type=str,
help="Override country text displayed on poster",
)
parser.add_argument(
"--theme",
"-t",
type=str,
default="terracotta",
help="Theme name (default: terracotta)",
)
parser.add_argument(
"--all-themes",
"--All-themes",
dest="all_themes",
action="store_true",
help="Generate posters for all themes",
)
parser.add_argument(
"--distance",
"-d",
type=int,
default=18000,
help="Map radius in meters (default: 18000)",
)
parser.add_argument(
"--width",
"-W",
type=float,
default=12,
help="Image width in inches (default: 12, max: 20 )",
)
parser.add_argument(
"--height",
"-H",
type=float,
default=16,
help="Image height in inches (default: 16, max: 20)",
)
parser.add_argument(
"--list-themes", action="store_true", help="List all available themes"
)
parser.add_argument(
"--display-city",
"-dc",
type=str,
help="Custom display name for city (for i18n support)",
)
parser.add_argument(
"--display-country",
"-dC",
type=str,
help="Custom display name for country (for i18n support)",
)
parser.add_argument(
"--font-family",
type=str,
help='Google Fonts family name (e.g., "Noto Sans JP", "Open Sans"). If not specified, uses local Roboto fonts.',
)
parser.add_argument(
"--format",
"-f",
default="png",
choices=["png", "svg", "pdf"],
help="Output format for the poster (default: png)",
)
args = parser.parse_args()
# If no arguments provided, show examples
if len(sys.argv) == 1:
print_examples()
sys.exit(0)
# List themes if requested
if args.list_themes:
list_themes()
sys.exit(0)
# Validate required arguments
if not args.city or not args.country:
print("Error: --city and --country are required.\n")
print_examples()
sys.exit(1)
# Enforce maximum dimensions
if args.width > 20:
print(
f"⚠ Width {args.width} exceeds the maximum allowed limit of 20. It's enforced as max limit 20."
)
args.width = 20.0
if args.height > 20:
print(
f"⚠ Height {args.height} exceeds the maximum allowed limit of 20. It's enforced as max limit 20."
)
args.height = 20.0
available_themes = get_available_themes()
if not available_themes:
print("No themes found in 'themes/' directory.")
sys.exit(1)
if args.all_themes:
themes_to_generate = available_themes
else:
if args.theme not in available_themes:
print(f"Error: Theme '{args.theme}' not found.")
print(f"Available themes: {', '.join(available_themes)}")
sys.exit(1)