fffiloni commited on
Commit
34b0ee2
·
verified ·
1 Parent(s): 09cb424

Crop map to poster aspect ratio to reduce empty margins

Browse files
Files changed (1) hide show
  1. 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=(12, 16), facecolor=THEME['bg'])
263
  ax.set_facecolor(THEME['bg'])
264
- ax.set_position([0, 0, 1, 1])
265
-
 
 
 
266
  # 3. Plot Layers
267
  # Layer 1: Polygons
268
- if water is not None and not water.empty:
269
  water.plot(ax=ax, facecolor=THEME['water'], edgecolor='none', zorder=1)
270
- if parks is not None and not parks.empty:
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)