Spaces:
Sleeping
Sleeping
Crop map to poster aspect ratio to reduce empty margins
Browse files- create_map_poster.py +121 -63
create_map_poster.py
CHANGED
|
@@ -15,6 +15,11 @@ THEMES_DIR = "themes"
|
|
| 15 |
FONTS_DIR = "fonts"
|
| 16 |
POSTERS_DIR = "posters"
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def load_fonts():
|
| 19 |
"""
|
| 20 |
Load Roboto fonts from the fonts directory.
|
|
@@ -25,13 +30,13 @@ def load_fonts():
|
|
| 25 |
'regular': os.path.join(FONTS_DIR, 'Roboto-Regular.ttf'),
|
| 26 |
'light': os.path.join(FONTS_DIR, 'Roboto-Light.ttf')
|
| 27 |
}
|
| 28 |
-
|
| 29 |
# Verify fonts exist
|
| 30 |
for weight, path in fonts.items():
|
| 31 |
if not os.path.exists(path):
|
| 32 |
print(f"⚠ Font not found: {path}")
|
| 33 |
return None
|
| 34 |
-
|
| 35 |
return fonts
|
| 36 |
|
| 37 |
FONTS = load_fonts()
|
|
@@ -42,7 +47,7 @@ def generate_output_filename(city, theme_name):
|
|
| 42 |
"""
|
| 43 |
if not os.path.exists(POSTERS_DIR):
|
| 44 |
os.makedirs(POSTERS_DIR)
|
| 45 |
-
|
| 46 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 47 |
city_slug = city.lower().replace(' ', '_')
|
| 48 |
filename = f"{city_slug}_{theme_name}_{timestamp}.png"
|
|
@@ -55,7 +60,7 @@ def get_available_themes():
|
|
| 55 |
if not os.path.exists(THEMES_DIR):
|
| 56 |
os.makedirs(THEMES_DIR)
|
| 57 |
return []
|
| 58 |
-
|
| 59 |
themes = []
|
| 60 |
for file in sorted(os.listdir(THEMES_DIR)):
|
| 61 |
if file.endswith('.json'):
|
|
@@ -68,7 +73,7 @@ def load_theme(theme_name="feature_based"):
|
|
| 68 |
Load theme from JSON file in themes directory.
|
| 69 |
"""
|
| 70 |
theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json")
|
| 71 |
-
|
| 72 |
if not os.path.exists(theme_file):
|
| 73 |
print(f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme.")
|
| 74 |
# Fallback to embedded default theme
|
|
@@ -86,7 +91,7 @@ def load_theme(theme_name="feature_based"):
|
|
| 86 |
"road_residential": "#4A4A4A",
|
| 87 |
"road_default": "#3A3A3A"
|
| 88 |
}
|
| 89 |
-
|
| 90 |
with open(theme_file, 'r') as f:
|
| 91 |
theme = json.load(f)
|
| 92 |
print(f"✓ Loaded theme: {theme.get('name', theme_name)}")
|
|
@@ -97,6 +102,37 @@ def load_theme(theme_name="feature_based"):
|
|
| 97 |
# Load theme (can be changed via command line or input)
|
| 98 |
THEME = None # Will be loaded later
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def create_gradient_fade(ax, color, location='bottom', zorder=10):
|
| 101 |
"""
|
| 102 |
Creates a fade effect at the top or bottom of the map
|
|
@@ -151,15 +187,15 @@ def get_edge_colors_by_type(G):
|
|
| 151 |
Returns a list of colors corresponding to each edge in the graph.
|
| 152 |
"""
|
| 153 |
edge_colors = []
|
| 154 |
-
|
| 155 |
for u, v, data in G.edges(data=True):
|
| 156 |
# Get the highway type (can be a list or string)
|
| 157 |
highway = data.get('highway', 'unclassified')
|
| 158 |
-
|
| 159 |
# Handle list of highway types (take the first one)
|
| 160 |
if isinstance(highway, list):
|
| 161 |
highway = highway[0] if highway else 'unclassified'
|
| 162 |
-
|
| 163 |
# Assign color based on road type
|
| 164 |
if highway in ['motorway', 'motorway_link']:
|
| 165 |
color = THEME['road_motorway']
|
|
@@ -173,9 +209,9 @@ def get_edge_colors_by_type(G):
|
|
| 173 |
color = THEME['road_residential']
|
| 174 |
else:
|
| 175 |
color = THEME['road_default']
|
| 176 |
-
|
| 177 |
edge_colors.append(color)
|
| 178 |
-
|
| 179 |
return edge_colors
|
| 180 |
|
| 181 |
def get_edge_widths_by_type(G):
|
|
@@ -184,13 +220,13 @@ def get_edge_widths_by_type(G):
|
|
| 184 |
Major roads get thicker lines.
|
| 185 |
"""
|
| 186 |
edge_widths = []
|
| 187 |
-
|
| 188 |
for u, v, data in G.edges(data=True):
|
| 189 |
highway = data.get('highway', 'unclassified')
|
| 190 |
-
|
| 191 |
if isinstance(highway, list):
|
| 192 |
highway = highway[0] if highway else 'unclassified'
|
| 193 |
-
|
| 194 |
# Assign width based on road importance
|
| 195 |
if highway in ['motorway', 'motorway_link']:
|
| 196 |
width = 1.2
|
|
@@ -202,9 +238,9 @@ def get_edge_widths_by_type(G):
|
|
| 202 |
width = 0.6
|
| 203 |
else:
|
| 204 |
width = 0.4
|
| 205 |
-
|
| 206 |
edge_widths.append(width)
|
| 207 |
-
|
| 208 |
return edge_widths
|
| 209 |
|
| 210 |
def get_coordinates(city, country):
|
|
@@ -214,12 +250,12 @@ def get_coordinates(city, country):
|
|
| 214 |
"""
|
| 215 |
print("Looking up coordinates...")
|
| 216 |
geolocator = Nominatim(user_agent="city_map_poster")
|
| 217 |
-
|
| 218 |
# Add a small delay to respect Nominatim's usage policy
|
| 219 |
time.sleep(1)
|
| 220 |
-
|
| 221 |
location = geolocator.geocode(f"{city}, {country}")
|
| 222 |
-
|
| 223 |
if location:
|
| 224 |
print(f"✓ Found: {location.address}")
|
| 225 |
print(f"✓ Coordinates: {location.latitude}, {location.longitude}")
|
|
@@ -229,7 +265,7 @@ def get_coordinates(city, country):
|
|
| 229 |
|
| 230 |
def create_poster(city, country, point, dist, output_file):
|
| 231 |
print(f"\nGenerating map for {city}, {country}...")
|
| 232 |
-
|
| 233 |
# Progress bar for data fetching
|
| 234 |
with tqdm(total=3, desc="Fetching map data", unit="step", bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar:
|
| 235 |
# 1. Fetch Street Network
|
|
@@ -237,7 +273,7 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 237 |
G = ox.graph_from_point(point, dist=dist, dist_type='bbox', network_type='all')
|
| 238 |
pbar.update(1)
|
| 239 |
time.sleep(0.5) # Rate limit between requests
|
| 240 |
-
|
| 241 |
# 2. Fetch Water Features
|
| 242 |
pbar.set_description("Downloading water features")
|
| 243 |
try:
|
|
@@ -246,7 +282,7 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 246 |
water = None
|
| 247 |
pbar.update(1)
|
| 248 |
time.sleep(0.3)
|
| 249 |
-
|
| 250 |
# 3. Fetch Parks
|
| 251 |
pbar.set_description("Downloading parks/green spaces")
|
| 252 |
try:
|
|
@@ -254,27 +290,46 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 254 |
except:
|
| 255 |
parks = None
|
| 256 |
pbar.update(1)
|
| 257 |
-
|
| 258 |
print("✓ All data downloaded successfully!")
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
# 2. Setup Plot
|
| 261 |
print("Rendering map...")
|
| 262 |
-
fig, ax = plt.subplots(figsize=
|
| 263 |
ax.set_facecolor(THEME['bg'])
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
| 266 |
# 3. Plot Layers
|
| 267 |
# Layer 1: Polygons
|
| 268 |
-
if water is not None and not water
|
| 269 |
water.plot(ax=ax, facecolor=THEME['water'], edgecolor='none', zorder=1)
|
| 270 |
-
if parks is not None and not parks
|
| 271 |
parks.plot(ax=ax, facecolor=THEME['parks'], edgecolor='none', zorder=2)
|
| 272 |
-
|
| 273 |
# Layer 2: Roads with hierarchy coloring
|
| 274 |
print("Applying road hierarchy colors...")
|
| 275 |
edge_colors = get_edge_colors_by_type(G)
|
| 276 |
edge_widths = get_edge_widths_by_type(G)
|
| 277 |
-
|
| 278 |
ox.plot_graph(
|
| 279 |
G, ax=ax, bgcolor=THEME['bg'],
|
| 280 |
node_size=0,
|
|
@@ -282,42 +337,45 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 282 |
edge_linewidth=edge_widths,
|
| 283 |
show=False, close=False
|
| 284 |
)
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
# Layer 3: Gradients (Top and Bottom)
|
| 287 |
create_gradient_fade(ax, THEME['gradient_color'], location='bottom', zorder=10)
|
| 288 |
-
create_gradient_fade(ax, THEME['gradient_color'], location='top', zorder=10)
|
| 289 |
-
|
| 290 |
# 4. Typography using Roboto font
|
| 291 |
if FONTS:
|
| 292 |
font_main = FontProperties(fname=FONTS['bold'], size=60)
|
| 293 |
-
font_top = FontProperties(fname=FONTS['bold'], size=40)
|
| 294 |
font_sub = FontProperties(fname=FONTS['light'], size=22)
|
| 295 |
font_coords = FontProperties(fname=FONTS['regular'], size=14)
|
| 296 |
else:
|
| 297 |
# Fallback to system fonts
|
| 298 |
font_main = FontProperties(family='monospace', weight='bold', size=60)
|
| 299 |
-
font_top = FontProperties(family='monospace', weight='bold', size=40)
|
| 300 |
font_sub = FontProperties(family='monospace', weight='normal', size=22)
|
| 301 |
font_coords = FontProperties(family='monospace', size=14)
|
| 302 |
-
|
| 303 |
spaced_city = " ".join(list(city.upper()))
|
| 304 |
|
| 305 |
# --- BOTTOM TEXT ---
|
| 306 |
ax.text(0.5, 0.14, spaced_city, transform=ax.transAxes,
|
| 307 |
color=THEME['text'], ha='center', fontproperties=font_main, zorder=11)
|
| 308 |
-
|
| 309 |
ax.text(0.5, 0.10, country.upper(), transform=ax.transAxes,
|
| 310 |
color=THEME['text'], ha='center', fontproperties=font_sub, zorder=11)
|
| 311 |
-
|
| 312 |
lat, lon = point
|
| 313 |
coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E"
|
| 314 |
if lon < 0:
|
| 315 |
coords = coords.replace("E", "W")
|
| 316 |
-
|
| 317 |
ax.text(0.5, 0.07, coords, transform=ax.transAxes,
|
| 318 |
color=THEME['text'], alpha=0.7, ha='center', fontproperties=font_coords, zorder=11)
|
| 319 |
-
|
| 320 |
-
ax.plot([0.4, 0.6], [0.125, 0.125], transform=ax.transAxes,
|
| 321 |
color=THEME['text'], linewidth=1, zorder=11)
|
| 322 |
|
| 323 |
# --- ATTRIBUTION (bottom right) ---
|
|
@@ -325,9 +383,9 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 325 |
font_attr = FontProperties(fname=FONTS['light'], size=8)
|
| 326 |
else:
|
| 327 |
font_attr = FontProperties(family='monospace', size=8)
|
| 328 |
-
|
| 329 |
ax.text(0.98, 0.02, "© OpenStreetMap contributors", transform=ax.transAxes,
|
| 330 |
-
color=THEME['text'], alpha=0.5, ha='right', va='bottom',
|
| 331 |
fontproperties=font_attr, zorder=11)
|
| 332 |
|
| 333 |
# 5. Save
|
|
@@ -349,30 +407,30 @@ Examples:
|
|
| 349 |
# Iconic grid patterns
|
| 350 |
python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid
|
| 351 |
python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid
|
| 352 |
-
|
| 353 |
# Waterfront & canals
|
| 354 |
python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network
|
| 355 |
python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals
|
| 356 |
python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline
|
| 357 |
-
|
| 358 |
# Radial patterns
|
| 359 |
python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards
|
| 360 |
python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads
|
| 361 |
-
|
| 362 |
# Organic old cities
|
| 363 |
python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets
|
| 364 |
python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze
|
| 365 |
python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient street layout
|
| 366 |
-
|
| 367 |
# Coastal cities
|
| 368 |
python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid
|
| 369 |
python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city
|
| 370 |
python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula
|
| 371 |
-
|
| 372 |
# River cities
|
| 373 |
python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves
|
| 374 |
python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split
|
| 375 |
-
|
| 376 |
# List themes
|
| 377 |
python create_map_poster.py --list-themes
|
| 378 |
|
|
@@ -398,7 +456,7 @@ def list_themes():
|
|
| 398 |
if not available_themes:
|
| 399 |
print("No themes found in 'themes/' directory.")
|
| 400 |
return
|
| 401 |
-
|
| 402 |
print("\nAvailable Themes:")
|
| 403 |
print("-" * 60)
|
| 404 |
for theme_name in available_themes:
|
|
@@ -429,57 +487,57 @@ Examples:
|
|
| 429 |
python create_map_poster.py --list-themes
|
| 430 |
"""
|
| 431 |
)
|
| 432 |
-
|
| 433 |
parser.add_argument('--city', '-c', type=str, help='City name')
|
| 434 |
parser.add_argument('--country', '-C', type=str, help='Country name')
|
| 435 |
parser.add_argument('--theme', '-t', type=str, default='feature_based', help='Theme name (default: feature_based)')
|
| 436 |
parser.add_argument('--distance', '-d', type=int, default=29000, help='Map radius in meters (default: 29000)')
|
| 437 |
parser.add_argument('--list-themes', action='store_true', help='List all available themes')
|
| 438 |
-
|
| 439 |
args = parser.parse_args()
|
| 440 |
-
|
| 441 |
# If no arguments provided, show examples
|
| 442 |
if len(os.sys.argv) == 1:
|
| 443 |
print_examples()
|
| 444 |
os.sys.exit(0)
|
| 445 |
-
|
| 446 |
# List themes if requested
|
| 447 |
if args.list_themes:
|
| 448 |
list_themes()
|
| 449 |
os.sys.exit(0)
|
| 450 |
-
|
| 451 |
# Validate required arguments
|
| 452 |
if not args.city or not args.country:
|
| 453 |
print("Error: --city and --country are required.\n")
|
| 454 |
print_examples()
|
| 455 |
os.sys.exit(1)
|
| 456 |
-
|
| 457 |
# Validate theme exists
|
| 458 |
available_themes = get_available_themes()
|
| 459 |
if args.theme not in available_themes:
|
| 460 |
print(f"Error: Theme '{args.theme}' not found.")
|
| 461 |
print(f"Available themes: {', '.join(available_themes)}")
|
| 462 |
os.sys.exit(1)
|
| 463 |
-
|
| 464 |
print("=" * 50)
|
| 465 |
print("City Map Poster Generator")
|
| 466 |
print("=" * 50)
|
| 467 |
-
|
| 468 |
# Load theme
|
| 469 |
THEME = load_theme(args.theme)
|
| 470 |
-
|
| 471 |
# Get coordinates and generate poster
|
| 472 |
try:
|
| 473 |
coords = get_coordinates(args.city, args.country)
|
| 474 |
output_file = generate_output_filename(args.city, args.theme)
|
| 475 |
create_poster(args.city, args.country, coords, args.distance, output_file)
|
| 476 |
-
|
| 477 |
print("\n" + "=" * 50)
|
| 478 |
print("✓ Poster generation complete!")
|
| 479 |
print("=" * 50)
|
| 480 |
-
|
| 481 |
except Exception as e:
|
| 482 |
print(f"\n✗ Error: {e}")
|
| 483 |
import traceback
|
| 484 |
traceback.print_exc()
|
| 485 |
-
os.sys.exit(1)
|
|
|
|
| 15 |
FONTS_DIR = "fonts"
|
| 16 |
POSTERS_DIR = "posters"
|
| 17 |
|
| 18 |
+
# ---- Layout tuning (easy knobs) ----
|
| 19 |
+
FIGSIZE = (12, 16) # Poster size in inches
|
| 20 |
+
MAP_POS = (0.03, 0.18, 0.94, 0.80) # [left, bottom, width, height] in figure coords
|
| 21 |
+
MAP_PAD = 0.03 # Crop padding fraction (3% margin)
|
| 22 |
+
|
| 23 |
def load_fonts():
|
| 24 |
"""
|
| 25 |
Load Roboto fonts from the fonts directory.
|
|
|
|
| 30 |
'regular': os.path.join(FONTS_DIR, 'Roboto-Regular.ttf'),
|
| 31 |
'light': os.path.join(FONTS_DIR, 'Roboto-Light.ttf')
|
| 32 |
}
|
| 33 |
+
|
| 34 |
# Verify fonts exist
|
| 35 |
for weight, path in fonts.items():
|
| 36 |
if not os.path.exists(path):
|
| 37 |
print(f"⚠ Font not found: {path}")
|
| 38 |
return None
|
| 39 |
+
|
| 40 |
return fonts
|
| 41 |
|
| 42 |
FONTS = load_fonts()
|
|
|
|
| 47 |
"""
|
| 48 |
if not os.path.exists(POSTERS_DIR):
|
| 49 |
os.makedirs(POSTERS_DIR)
|
| 50 |
+
|
| 51 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 52 |
city_slug = city.lower().replace(' ', '_')
|
| 53 |
filename = f"{city_slug}_{theme_name}_{timestamp}.png"
|
|
|
|
| 60 |
if not os.path.exists(THEMES_DIR):
|
| 61 |
os.makedirs(THEMES_DIR)
|
| 62 |
return []
|
| 63 |
+
|
| 64 |
themes = []
|
| 65 |
for file in sorted(os.listdir(THEMES_DIR)):
|
| 66 |
if file.endswith('.json'):
|
|
|
|
| 73 |
Load theme from JSON file in themes directory.
|
| 74 |
"""
|
| 75 |
theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json")
|
| 76 |
+
|
| 77 |
if not os.path.exists(theme_file):
|
| 78 |
print(f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme.")
|
| 79 |
# Fallback to embedded default theme
|
|
|
|
| 91 |
"road_residential": "#4A4A4A",
|
| 92 |
"road_default": "#3A3A3A"
|
| 93 |
}
|
| 94 |
+
|
| 95 |
with open(theme_file, 'r') as f:
|
| 96 |
theme = json.load(f)
|
| 97 |
print(f"✓ Loaded theme: {theme.get('name', theme_name)}")
|
|
|
|
| 102 |
# Load theme (can be changed via command line or input)
|
| 103 |
THEME = None # Will be loaded later
|
| 104 |
|
| 105 |
+
def crop_axes_to_ratio(ax, target_ratio, pad=0.03):
|
| 106 |
+
"""
|
| 107 |
+
Crop current ax limits to match target width/height ratio.
|
| 108 |
+
target_ratio = width/height
|
| 109 |
+
pad is a fraction (e.g. 0.03 = 3%) to keep small margins.
|
| 110 |
+
"""
|
| 111 |
+
x0, x1 = ax.get_xlim()
|
| 112 |
+
y0, y1 = ax.get_ylim()
|
| 113 |
+
|
| 114 |
+
w = x1 - x0
|
| 115 |
+
h = y1 - y0
|
| 116 |
+
if w <= 0 or h <= 0:
|
| 117 |
+
return
|
| 118 |
+
|
| 119 |
+
cx = (x0 + x1) / 2
|
| 120 |
+
cy = (y0 + y1) / 2
|
| 121 |
+
current_ratio = w / h
|
| 122 |
+
|
| 123 |
+
shrink = max(0.0, min(0.2, float(pad)))
|
| 124 |
+
|
| 125 |
+
if current_ratio > target_ratio:
|
| 126 |
+
# too wide -> reduce width
|
| 127 |
+
new_w = h * target_ratio
|
| 128 |
+
new_w *= (1 - shrink)
|
| 129 |
+
ax.set_xlim(cx - new_w / 2, cx + new_w / 2)
|
| 130 |
+
else:
|
| 131 |
+
# too tall -> reduce height
|
| 132 |
+
new_h = w / target_ratio
|
| 133 |
+
new_h *= (1 - shrink)
|
| 134 |
+
ax.set_ylim(cy - new_h / 2, cy + new_h / 2)
|
| 135 |
+
|
| 136 |
def create_gradient_fade(ax, color, location='bottom', zorder=10):
|
| 137 |
"""
|
| 138 |
Creates a fade effect at the top or bottom of the map
|
|
|
|
| 187 |
Returns a list of colors corresponding to each edge in the graph.
|
| 188 |
"""
|
| 189 |
edge_colors = []
|
| 190 |
+
|
| 191 |
for u, v, data in G.edges(data=True):
|
| 192 |
# Get the highway type (can be a list or string)
|
| 193 |
highway = data.get('highway', 'unclassified')
|
| 194 |
+
|
| 195 |
# Handle list of highway types (take the first one)
|
| 196 |
if isinstance(highway, list):
|
| 197 |
highway = highway[0] if highway else 'unclassified'
|
| 198 |
+
|
| 199 |
# Assign color based on road type
|
| 200 |
if highway in ['motorway', 'motorway_link']:
|
| 201 |
color = THEME['road_motorway']
|
|
|
|
| 209 |
color = THEME['road_residential']
|
| 210 |
else:
|
| 211 |
color = THEME['road_default']
|
| 212 |
+
|
| 213 |
edge_colors.append(color)
|
| 214 |
+
|
| 215 |
return edge_colors
|
| 216 |
|
| 217 |
def get_edge_widths_by_type(G):
|
|
|
|
| 220 |
Major roads get thicker lines.
|
| 221 |
"""
|
| 222 |
edge_widths = []
|
| 223 |
+
|
| 224 |
for u, v, data in G.edges(data=True):
|
| 225 |
highway = data.get('highway', 'unclassified')
|
| 226 |
+
|
| 227 |
if isinstance(highway, list):
|
| 228 |
highway = highway[0] if highway else 'unclassified'
|
| 229 |
+
|
| 230 |
# Assign width based on road importance
|
| 231 |
if highway in ['motorway', 'motorway_link']:
|
| 232 |
width = 1.2
|
|
|
|
| 238 |
width = 0.6
|
| 239 |
else:
|
| 240 |
width = 0.4
|
| 241 |
+
|
| 242 |
edge_widths.append(width)
|
| 243 |
+
|
| 244 |
return edge_widths
|
| 245 |
|
| 246 |
def get_coordinates(city, country):
|
|
|
|
| 250 |
"""
|
| 251 |
print("Looking up coordinates...")
|
| 252 |
geolocator = Nominatim(user_agent="city_map_poster")
|
| 253 |
+
|
| 254 |
# Add a small delay to respect Nominatim's usage policy
|
| 255 |
time.sleep(1)
|
| 256 |
+
|
| 257 |
location = geolocator.geocode(f"{city}, {country}")
|
| 258 |
+
|
| 259 |
if location:
|
| 260 |
print(f"✓ Found: {location.address}")
|
| 261 |
print(f"✓ Coordinates: {location.latitude}, {location.longitude}")
|
|
|
|
| 265 |
|
| 266 |
def create_poster(city, country, point, dist, output_file):
|
| 267 |
print(f"\nGenerating map for {city}, {country}...")
|
| 268 |
+
|
| 269 |
# Progress bar for data fetching
|
| 270 |
with tqdm(total=3, desc="Fetching map data", unit="step", bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar:
|
| 271 |
# 1. Fetch Street Network
|
|
|
|
| 273 |
G = ox.graph_from_point(point, dist=dist, dist_type='bbox', network_type='all')
|
| 274 |
pbar.update(1)
|
| 275 |
time.sleep(0.5) # Rate limit between requests
|
| 276 |
+
|
| 277 |
# 2. Fetch Water Features
|
| 278 |
pbar.set_description("Downloading water features")
|
| 279 |
try:
|
|
|
|
| 282 |
water = None
|
| 283 |
pbar.update(1)
|
| 284 |
time.sleep(0.3)
|
| 285 |
+
|
| 286 |
# 3. Fetch Parks
|
| 287 |
pbar.set_description("Downloading parks/green spaces")
|
| 288 |
try:
|
|
|
|
| 290 |
except:
|
| 291 |
parks = None
|
| 292 |
pbar.update(1)
|
| 293 |
+
|
| 294 |
print("✓ All data downloaded successfully!")
|
| 295 |
+
|
| 296 |
+
# --- Project to metric CRS (stabilizes aspect + enables correct cropping) ---
|
| 297 |
+
G = ox.project_graph(G)
|
| 298 |
+
crs = G.graph.get("crs", None)
|
| 299 |
+
|
| 300 |
+
if crs is not None:
|
| 301 |
+
try:
|
| 302 |
+
if water is not None and hasattr(water, "to_crs") and not water.empty:
|
| 303 |
+
water = water.to_crs(crs)
|
| 304 |
+
except:
|
| 305 |
+
pass
|
| 306 |
+
try:
|
| 307 |
+
if parks is not None and hasattr(parks, "to_crs") and not parks.empty:
|
| 308 |
+
parks = parks.to_crs(crs)
|
| 309 |
+
except:
|
| 310 |
+
pass
|
| 311 |
+
|
| 312 |
# 2. Setup Plot
|
| 313 |
print("Rendering map...")
|
| 314 |
+
fig, ax = plt.subplots(figsize=FIGSIZE, facecolor=THEME['bg'])
|
| 315 |
ax.set_facecolor(THEME['bg'])
|
| 316 |
+
|
| 317 |
+
# Reserve a dedicated map area (leave room for bottom typography)
|
| 318 |
+
ax.set_position(list(MAP_POS))
|
| 319 |
+
map_h_frac = MAP_POS[3]
|
| 320 |
+
|
| 321 |
# 3. Plot Layers
|
| 322 |
# Layer 1: Polygons
|
| 323 |
+
if water is not None and not getattr(water, "empty", True):
|
| 324 |
water.plot(ax=ax, facecolor=THEME['water'], edgecolor='none', zorder=1)
|
| 325 |
+
if parks is not None and not getattr(parks, "empty", True):
|
| 326 |
parks.plot(ax=ax, facecolor=THEME['parks'], edgecolor='none', zorder=2)
|
| 327 |
+
|
| 328 |
# Layer 2: Roads with hierarchy coloring
|
| 329 |
print("Applying road hierarchy colors...")
|
| 330 |
edge_colors = get_edge_colors_by_type(G)
|
| 331 |
edge_widths = get_edge_widths_by_type(G)
|
| 332 |
+
|
| 333 |
ox.plot_graph(
|
| 334 |
G, ax=ax, bgcolor=THEME['bg'],
|
| 335 |
node_size=0,
|
|
|
|
| 337 |
edge_linewidth=edge_widths,
|
| 338 |
show=False, close=False
|
| 339 |
)
|
| 340 |
+
|
| 341 |
+
# Crop view to match the poster/map area aspect ratio (in projected units)
|
| 342 |
+
fig_w, fig_h = fig.get_size_inches()
|
| 343 |
+
target_ratio = fig_w / (fig_h * map_h_frac) # width/height of the map area
|
| 344 |
+
crop_axes_to_ratio(ax, target_ratio=target_ratio, pad=MAP_PAD)
|
| 345 |
+
|
| 346 |
# Layer 3: Gradients (Top and Bottom)
|
| 347 |
create_gradient_fade(ax, THEME['gradient_color'], location='bottom', zorder=10)
|
| 348 |
+
create_gradient_fade(ax, THEME['gradient_color'], location='top', zorder=10)
|
| 349 |
+
|
| 350 |
# 4. Typography using Roboto font
|
| 351 |
if FONTS:
|
| 352 |
font_main = FontProperties(fname=FONTS['bold'], size=60)
|
|
|
|
| 353 |
font_sub = FontProperties(fname=FONTS['light'], size=22)
|
| 354 |
font_coords = FontProperties(fname=FONTS['regular'], size=14)
|
| 355 |
else:
|
| 356 |
# Fallback to system fonts
|
| 357 |
font_main = FontProperties(family='monospace', weight='bold', size=60)
|
|
|
|
| 358 |
font_sub = FontProperties(family='monospace', weight='normal', size=22)
|
| 359 |
font_coords = FontProperties(family='monospace', size=14)
|
| 360 |
+
|
| 361 |
spaced_city = " ".join(list(city.upper()))
|
| 362 |
|
| 363 |
# --- BOTTOM TEXT ---
|
| 364 |
ax.text(0.5, 0.14, spaced_city, transform=ax.transAxes,
|
| 365 |
color=THEME['text'], ha='center', fontproperties=font_main, zorder=11)
|
| 366 |
+
|
| 367 |
ax.text(0.5, 0.10, country.upper(), transform=ax.transAxes,
|
| 368 |
color=THEME['text'], ha='center', fontproperties=font_sub, zorder=11)
|
| 369 |
+
|
| 370 |
lat, lon = point
|
| 371 |
coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E"
|
| 372 |
if lon < 0:
|
| 373 |
coords = coords.replace("E", "W")
|
| 374 |
+
|
| 375 |
ax.text(0.5, 0.07, coords, transform=ax.transAxes,
|
| 376 |
color=THEME['text'], alpha=0.7, ha='center', fontproperties=font_coords, zorder=11)
|
| 377 |
+
|
| 378 |
+
ax.plot([0.4, 0.6], [0.125, 0.125], transform=ax.transAxes,
|
| 379 |
color=THEME['text'], linewidth=1, zorder=11)
|
| 380 |
|
| 381 |
# --- ATTRIBUTION (bottom right) ---
|
|
|
|
| 383 |
font_attr = FontProperties(fname=FONTS['light'], size=8)
|
| 384 |
else:
|
| 385 |
font_attr = FontProperties(family='monospace', size=8)
|
| 386 |
+
|
| 387 |
ax.text(0.98, 0.02, "© OpenStreetMap contributors", transform=ax.transAxes,
|
| 388 |
+
color=THEME['text'], alpha=0.5, ha='right', va='bottom',
|
| 389 |
fontproperties=font_attr, zorder=11)
|
| 390 |
|
| 391 |
# 5. Save
|
|
|
|
| 407 |
# Iconic grid patterns
|
| 408 |
python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid
|
| 409 |
python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid
|
| 410 |
+
|
| 411 |
# Waterfront & canals
|
| 412 |
python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network
|
| 413 |
python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals
|
| 414 |
python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline
|
| 415 |
+
|
| 416 |
# Radial patterns
|
| 417 |
python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards
|
| 418 |
python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads
|
| 419 |
+
|
| 420 |
# Organic old cities
|
| 421 |
python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets
|
| 422 |
python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze
|
| 423 |
python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient street layout
|
| 424 |
+
|
| 425 |
# Coastal cities
|
| 426 |
python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid
|
| 427 |
python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city
|
| 428 |
python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula
|
| 429 |
+
|
| 430 |
# River cities
|
| 431 |
python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves
|
| 432 |
python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split
|
| 433 |
+
|
| 434 |
# List themes
|
| 435 |
python create_map_poster.py --list-themes
|
| 436 |
|
|
|
|
| 456 |
if not available_themes:
|
| 457 |
print("No themes found in 'themes/' directory.")
|
| 458 |
return
|
| 459 |
+
|
| 460 |
print("\nAvailable Themes:")
|
| 461 |
print("-" * 60)
|
| 462 |
for theme_name in available_themes:
|
|
|
|
| 487 |
python create_map_poster.py --list-themes
|
| 488 |
"""
|
| 489 |
)
|
| 490 |
+
|
| 491 |
parser.add_argument('--city', '-c', type=str, help='City name')
|
| 492 |
parser.add_argument('--country', '-C', type=str, help='Country name')
|
| 493 |
parser.add_argument('--theme', '-t', type=str, default='feature_based', help='Theme name (default: feature_based)')
|
| 494 |
parser.add_argument('--distance', '-d', type=int, default=29000, help='Map radius in meters (default: 29000)')
|
| 495 |
parser.add_argument('--list-themes', action='store_true', help='List all available themes')
|
| 496 |
+
|
| 497 |
args = parser.parse_args()
|
| 498 |
+
|
| 499 |
# If no arguments provided, show examples
|
| 500 |
if len(os.sys.argv) == 1:
|
| 501 |
print_examples()
|
| 502 |
os.sys.exit(0)
|
| 503 |
+
|
| 504 |
# List themes if requested
|
| 505 |
if args.list_themes:
|
| 506 |
list_themes()
|
| 507 |
os.sys.exit(0)
|
| 508 |
+
|
| 509 |
# Validate required arguments
|
| 510 |
if not args.city or not args.country:
|
| 511 |
print("Error: --city and --country are required.\n")
|
| 512 |
print_examples()
|
| 513 |
os.sys.exit(1)
|
| 514 |
+
|
| 515 |
# Validate theme exists
|
| 516 |
available_themes = get_available_themes()
|
| 517 |
if args.theme not in available_themes:
|
| 518 |
print(f"Error: Theme '{args.theme}' not found.")
|
| 519 |
print(f"Available themes: {', '.join(available_themes)}")
|
| 520 |
os.sys.exit(1)
|
| 521 |
+
|
| 522 |
print("=" * 50)
|
| 523 |
print("City Map Poster Generator")
|
| 524 |
print("=" * 50)
|
| 525 |
+
|
| 526 |
# Load theme
|
| 527 |
THEME = load_theme(args.theme)
|
| 528 |
+
|
| 529 |
# Get coordinates and generate poster
|
| 530 |
try:
|
| 531 |
coords = get_coordinates(args.city, args.country)
|
| 532 |
output_file = generate_output_filename(args.city, args.theme)
|
| 533 |
create_poster(args.city, args.country, coords, args.distance, output_file)
|
| 534 |
+
|
| 535 |
print("\n" + "=" * 50)
|
| 536 |
print("✓ Poster generation complete!")
|
| 537 |
print("=" * 50)
|
| 538 |
+
|
| 539 |
except Exception as e:
|
| 540 |
print(f"\n✗ Error: {e}")
|
| 541 |
import traceback
|
| 542 |
traceback.print_exc()
|
| 543 |
+
os.sys.exit(1)
|