isaachwf commited on
Commit
bc6fc76
·
1 Parent(s): f869ed5

feat: initial clean project structure with Git LFS

Browse files
.gitattributes CHANGED
@@ -33,6 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
36
  *.png filter=lfs diff=lfs merge=lfs -text
37
  *.JPG filter=lfs diff=lfs merge=lfs -text
38
  *.jpg filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ fonts/HYWenRunSongYunU.ttf filter=lfs diff=lfs merge=lfs -text
37
  *.png filter=lfs diff=lfs merge=lfs -text
38
  *.JPG filter=lfs diff=lfs merge=lfs -text
39
  *.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ cache/
2
+ posters/
3
+ __pycache__/
4
+ *.pyc
5
+ .DS_Store
6
+ .env
7
+ *.log
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ankur Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -11,3 +11,290 @@ license: mit
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
+
15
+ # City Map Poster Generator
16
+
17
+ Generate beautiful, minimalist map posters for any city in the world.
18
+
19
+ <img src="posters/singapore_neon_cyberpunk_20260108_184503.png" width="250">
20
+ <img src="posters/dubai_midnight_blue_20260108_174920.png" width="250">
21
+
22
+ ## Examples
23
+
24
+
25
+ | Country | City | Theme | Poster |
26
+ |:------------:|:--------------:|:---------------:|:------:|
27
+ | USA | San Francisco | sunset | <img src="posters/san_francisco_sunset_20260108_184122.png" width="250"> |
28
+ | Spain | Barcelona | warm_beige | <img src="posters/barcelona_warm_beige_20260108_172924.png" width="250"> |
29
+ | Italy | Venice | blueprint | <img src="posters/venice_blueprint_20260108_165527.png" width="250"> |
30
+ | Japan | Tokyo | japanese_ink | <img src="posters/tokyo_japanese_ink_20260108_165830.png" width="250"> |
31
+ | India | Mumbai | contrast_zones | <img src="posters/mumbai_contrast_zones_20260108_170325.png" width="250"> |
32
+ | Morocco | Marrakech | terracotta | <img src="posters/marrakech_terracotta_20260108_180821.png" width="250"> |
33
+ | Singapore | Singapore | neon_cyberpunk | <img src="posters/singapore_neon_cyberpunk_20260108_184503.png" width="250"> |
34
+ | Australia | Melbourne | forest | <img src="posters/melbourne_forest_20260108_181459.png" width="250"> |
35
+ | UAE | Dubai | midnight_blue | <img src="posters/dubai_midnight_blue_20260108_174920.png" width="250"> |
36
+
37
+ ## Installation
38
+
39
+ Recommended: Use `uv` to create a virtual environment and install dependencies:
40
+
41
+ ```bash
42
+ uv venv
43
+ source .venv/bin/activate
44
+ uv pip install -r requirements.txt
45
+ ```
46
+
47
+ Or simply sync with uv:
48
+
49
+ ```bash
50
+ uv sync
51
+ ```
52
+
53
+ ## Web UI (Gradio)
54
+
55
+ Launch the visual interface for interactive poster generation:
56
+
57
+ ```bash
58
+ uv run python app.py
59
+ ```
60
+
61
+ Then open [http://localhost:7860](http://localhost:7860) in your browser.
62
+
63
+ **Features:**
64
+ - 🔍 City search or cascading selection (Country → Province → City)
65
+ - 🎨 17 themes with color preview
66
+ - ⚙️ Adjustable parameters (distance, size, format)
67
+ - 🖼️ Real-time preview and download
68
+
69
+ ## Command Line Usage
70
+
71
+ If the virtual environment is activated:
72
+ ```bash
73
+ python create_map_poster.py --city <city> --country <country> [options]
74
+ ```
75
+
76
+ Or without activating:
77
+ ```bash
78
+ .venv/bin/python create_map_poster.py --city <city> --country <country> [options]
79
+ ```
80
+
81
+ ### Options
82
+
83
+ | Option | Short | Description | Default |
84
+ |--------|-------|-------------|---------|
85
+ | `--city` | `-c` | City name | required |
86
+ | `--country` | `-C` | Country name | required |
87
+ | `--theme` | `-t` | Theme name | feature_based |
88
+ | `--distance` | `-d` | Map radius in meters | 29000 |
89
+ | `--list-themes` | | List all available themes | |
90
+
91
+ ### Examples
92
+
93
+ ```bash
94
+ # Iconic grid patterns
95
+ python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid
96
+ python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district
97
+
98
+ # Waterfront & canals
99
+ python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network
100
+ python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals
101
+ python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline
102
+
103
+ # Radial patterns
104
+ python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards
105
+ python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads
106
+
107
+ # Organic old cities
108
+ python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets
109
+ python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze
110
+ python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient layout
111
+
112
+ # Coastal cities
113
+ python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid
114
+ python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city
115
+ python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula
116
+
117
+ # River cities
118
+ python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves
119
+ python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split
120
+
121
+ # List available themes
122
+ python create_map_poster.py --list-themes
123
+ ```
124
+
125
+ ### Distance Guide
126
+
127
+ | Distance | Best for |
128
+ |----------|----------|
129
+ | 4000-6000m | Small/dense cities (Venice, Amsterdam center) |
130
+ | 8000-12000m | Medium cities, focused downtown (Paris, Barcelona) |
131
+ | 15000-20000m | Large metros, full city view (Tokyo, Mumbai) |
132
+
133
+ ## Themes
134
+
135
+ 17 themes available in `themes/` directory:
136
+
137
+ | Theme | Style |
138
+ |-------|-------|
139
+ | `feature_based` | Classic black & white with road hierarchy |
140
+ | `gradient_roads` | Smooth gradient shading |
141
+ | `contrast_zones` | High contrast urban density |
142
+ | `noir` | Pure black background, white roads |
143
+ | `midnight_blue` | Navy background with gold roads |
144
+ | `blueprint` | Architectural blueprint aesthetic |
145
+ | `neon_cyberpunk` | Dark with electric pink/cyan |
146
+ | `warm_beige` | Vintage sepia tones |
147
+ | `pastel_dream` | Soft muted pastels |
148
+ | `japanese_ink` | Minimalist ink wash style |
149
+ | `forest` | Deep greens and sage |
150
+ | `ocean` | Blues and teals for coastal cities |
151
+ | `terracotta` | Mediterranean warmth |
152
+ | `sunset` | Warm oranges and pinks |
153
+ | `autumn` | Seasonal burnt oranges and reds |
154
+ | `copper_patina` | Oxidized copper aesthetic |
155
+ | `monochrome_blue` | Single blue color family |
156
+
157
+ ## Output
158
+
159
+ Posters are saved to `posters/` directory with format:
160
+ ```
161
+ {city}_{theme}_{YYYYMMDD_HHMMSS}.png
162
+ ```
163
+
164
+ ## Adding Custom Themes
165
+
166
+ Create a JSON file in `themes/` directory:
167
+
168
+ ```json
169
+ {
170
+ "name": "My Theme",
171
+ "description": "Description of the theme",
172
+ "bg": "#FFFFFF",
173
+ "text": "#000000",
174
+ "gradient_color": "#FFFFFF",
175
+ "water": "#C0C0C0",
176
+ "parks": "#F0F0F0",
177
+ "road_motorway": "#0A0A0A",
178
+ "road_primary": "#1A1A1A",
179
+ "road_secondary": "#2A2A2A",
180
+ "road_tertiary": "#3A3A3A",
181
+ "road_residential": "#4A4A4A",
182
+ "road_default": "#3A3A3A"
183
+ }
184
+ ```
185
+
186
+ ## Project Structure
187
+
188
+ ```
189
+ map_poster/
190
+ ├── create_map_poster.py # Main script
191
+ ├── themes/ # Theme JSON files
192
+ ├── fonts/ # Roboto font files
193
+ ├── posters/ # Generated posters
194
+ └── README.md
195
+ ```
196
+
197
+ ## Hacker's Guide
198
+
199
+ Quick reference for contributors who want to extend or modify the script.
200
+
201
+ ### Architecture Overview
202
+
203
+ ```
204
+ ┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
205
+ │ CLI Parser │────▶│ Geocoding │────▶│ Data Fetching │
206
+ │ (argparse) │ │ (Nominatim) │ │ (OSMnx) │
207
+ └─────────────────┘ └──────────────┘ └─────────────────┘
208
+
209
+ ┌──────────────┐ ▼
210
+ │ Output │◀────┌─────────────────┐
211
+ │ (matplotlib)│ │ Rendering │
212
+ └──────────────┘ │ (matplotlib) │
213
+ └─────────────────┘
214
+ ```
215
+
216
+ ### Key Functions
217
+
218
+ | Function | Purpose | Modify when... |
219
+ |----------|---------|----------------|
220
+ | `get_coordinates()` | City → lat/lon via Nominatim | Switching geocoding provider |
221
+ | `create_poster()` | Main rendering pipeline | Adding new map layers |
222
+ | `get_edge_colors_by_type()` | Road color by OSM highway tag | Changing road styling |
223
+ | `get_edge_widths_by_type()` | Road width by importance | Adjusting line weights |
224
+ | `create_gradient_fade()` | Top/bottom fade effect | Modifying gradient overlay |
225
+ | `load_theme()` | JSON theme → dict | Adding new theme properties |
226
+
227
+ ### Rendering Layers (z-order)
228
+
229
+ ```
230
+ z=11 Text labels (city, country, coords)
231
+ z=10 Gradient fades (top & bottom)
232
+ z=3 Roads (via ox.plot_graph)
233
+ z=2 Parks (green polygons)
234
+ z=1 Water (blue polygons)
235
+ z=0 Background color
236
+ ```
237
+
238
+ ### OSM Highway Types → Road Hierarchy
239
+
240
+ ```python
241
+ # In get_edge_colors_by_type() and get_edge_widths_by_type()
242
+ motorway, motorway_link → Thickest (1.2), darkest
243
+ trunk, primary → Thick (1.0)
244
+ secondary → Medium (0.8)
245
+ tertiary → Thin (0.6)
246
+ residential, living_street → Thinnest (0.4), lightest
247
+ ```
248
+
249
+ ### Adding New Features
250
+
251
+ **New map layer (e.g., railways):**
252
+ ```python
253
+ # In create_poster(), after parks fetch:
254
+ try:
255
+ railways = ox.features_from_point(point, tags={'railway': 'rail'}, dist=dist)
256
+ except:
257
+ railways = None
258
+
259
+ # Then plot before roads:
260
+ if railways is not None and not railways.empty:
261
+ railways.plot(ax=ax, color=THEME['railway'], linewidth=0.5, zorder=2.5)
262
+ ```
263
+
264
+ **New theme property:**
265
+ 1. Add to theme JSON: `"railway": "#FF0000"`
266
+ 2. Use in code: `THEME['railway']`
267
+ 3. Add fallback in `load_theme()` default dict
268
+
269
+ ### Typography Positioning
270
+
271
+ All text uses `transform=ax.transAxes` (0-1 normalized coordinates):
272
+ ```
273
+ y=0.14 City name (spaced letters)
274
+ y=0.125 Decorative line
275
+ y=0.10 Country name
276
+ y=0.07 Coordinates
277
+ y=0.02 Attribution (bottom-right)
278
+ ```
279
+
280
+ ### Useful OSMnx Patterns
281
+
282
+ ```python
283
+ # Get all buildings
284
+ buildings = ox.features_from_point(point, tags={'building': True}, dist=dist)
285
+
286
+ # Get specific amenities
287
+ cafes = ox.features_from_point(point, tags={'amenity': 'cafe'}, dist=dist)
288
+
289
+ # Different network types
290
+ G = ox.graph_from_point(point, dist=dist, network_type='drive') # roads only
291
+ G = ox.graph_from_point(point, dist=dist, network_type='bike') # bike paths
292
+ G = ox.graph_from_point(point, dist=dist, network_type='walk') # pedestrian
293
+ ```
294
+
295
+ ### Performance Tips
296
+
297
+ - Large `dist` values (>20km) = slow downloads + memory heavy
298
+ - Cache coordinates locally to avoid Nominatim rate limits
299
+ - Use `network_type='drive'` instead of `'all'` for faster renders
300
+ - Reduce `dpi` from 300 to 150 for quick previews
app.py ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Gradio Web Interface for City Map Poster Generator
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import gradio as gr
9
+ import tempfile
10
+ from datetime import datetime
11
+
12
+ # Import from the main module
13
+ from cities_data import (
14
+ CITIES,
15
+ get_countries,
16
+ get_provinces,
17
+ get_cities,
18
+ get_city_full_name,
19
+ translate,
20
+ get_country_key
21
+ )
22
+
23
+
24
+ # --- Constants ---
25
+ THEMES_DIR = "themes"
26
+ FONTS_DIR = "fonts"
27
+ POSTERS_DIR = "posters"
28
+
29
+ # Layer Constants
30
+ LAYERS_EN = ["Motorway", "Primary Roads", "Secondary Roads", "Water", "Parks"]
31
+ LAYERS_CN = ["高速公路", "主干道", "次干道", "水域", "公园"]
32
+ LAYER_KEYS = ["motorway", "primary", "secondary", "water", "parks"]
33
+
34
+
35
+
36
+ def get_available_themes():
37
+ """Scans the themes directory and returns a list of available theme names."""
38
+ if not os.path.exists(THEMES_DIR):
39
+ return []
40
+
41
+ themes = []
42
+ for file in sorted(os.listdir(THEMES_DIR)):
43
+ if file.endswith('.json'):
44
+ theme_name = file[:-5]
45
+ themes.append(theme_name)
46
+ return themes
47
+
48
+
49
+ def get_theme_choices(lang='en'):
50
+ """Return a list of (Display Name, Internal Name) tuples for themes."""
51
+ internal_names = get_available_themes()
52
+ choices = []
53
+
54
+ for internal_name in internal_names:
55
+ theme_info = load_theme_info(internal_name)
56
+ if theme_info:
57
+ proper_name = theme_info.get("name", internal_name)
58
+ display_name = translate(proper_name, lang)
59
+ choices.append((display_name, internal_name))
60
+ else:
61
+ choices.append((internal_name, internal_name))
62
+
63
+ return choices
64
+
65
+
66
+ def load_theme_info(theme_name):
67
+ """Load theme details for preview."""
68
+ theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json")
69
+ if os.path.exists(theme_file):
70
+ with open(theme_file, 'r') as f:
71
+ return json.load(f)
72
+ return None
73
+
74
+
75
+ def get_theme_preview_html(theme_name, lang='en'):
76
+ """Generate HTML preview for a theme."""
77
+ theme = load_theme_info(theme_name)
78
+ lang_code = 'en' if lang == "English" else 'cn'
79
+
80
+ if not theme:
81
+ return f"<p>{'Theme load failed' if lang_code == 'en' else '主题加载失败'}</p>"
82
+
83
+ # Translated labels
84
+ labels = {
85
+ "bg": "Background" if lang_code == "en" else "背景",
86
+ "text": "Text" if lang_code == "en" else "文字",
87
+ "motorway": "Motorway" if lang_code == "en" else "高速公路",
88
+ "primary": "Primary Road" if lang_code == "en" else "主干道",
89
+ "secondary": "Secondary Road" if lang_code == "en" else "次干道",
90
+ "water": "Water" if lang_code == "en" else "水域",
91
+ "parks": "Parks" if lang_code == "en" else "公园",
92
+ }
93
+
94
+ # Create color swatches
95
+ colors = [
96
+ (labels["bg"], theme.get("bg", "#FFFFFF")),
97
+ (labels["text"], theme.get("text", "#000000")),
98
+ (labels["motorway"], theme.get("road_motorway", "#000000")),
99
+ (labels["primary"], theme.get("road_primary", "#333333")),
100
+ (labels["secondary"], theme.get("road_secondary", "#666666")),
101
+ (labels["water"], theme.get("water", "#C0C0C0")),
102
+ (labels["parks"], theme.get("parks", "#F0F0F0")),
103
+ ]
104
+
105
+ display_name = translate(theme.get('name', theme_name), lang_code)
106
+ description = theme.get('description', '')
107
+ # Optional: translate description if we really want to, but might be too much work
108
+
109
+ html = f"""
110
+ <div style="padding: 12px; background: {theme.get('bg', '#FFFFFF')}; border-radius: 8px; border: 1px solid #ddd;">
111
+ <h4 style="color: {theme.get('text', '#000000')}; margin: 0 0 8px 0; font-size: 14px;">
112
+ {display_name}
113
+ </h4>
114
+ <p style="color: {theme.get('text', '#000000')}; opacity: 0.7; margin: 0 0 12px 0; font-size: 12px;">
115
+ {description}
116
+ </p>
117
+ <div style="display: flex; flex-wrap: wrap; gap: 6px;">
118
+ """
119
+
120
+ for label, color in colors:
121
+ html += f"""
122
+ <div style="display: flex; align-items: center; gap: 4px;">
123
+ <div style="width: 20px; height: 20px; background: {color}; border-radius: 4px; border: 1px solid #ccc;"></div>
124
+ <span style="font-size: 10px; color: {theme.get('text', '#000')}; opacity: 0.8;">{label}</span>
125
+ </div>
126
+ """
127
+
128
+ html += "</div></div>"
129
+ return html
130
+
131
+
132
+ def generate_poster(
133
+ country_display,
134
+ province,
135
+ city_dropdown,
136
+ theme_name,
137
+ distance,
138
+ width,
139
+ height,
140
+ output_format,
141
+ no_crop,
142
+ poster_lang,
143
+ layers_selection,
144
+ progress=gr.Progress()
145
+ ):
146
+ """
147
+ Generate the map poster with given parameters.
148
+ """
149
+ # Import here to avoid circular imports and ensure THEME is set correctly
150
+ import create_map_poster as cmp
151
+
152
+ # Decode layer selection
153
+ show_motorway = any(x in ["Motorway", "高速公路"] for x in layers_selection)
154
+ show_primary = any(x in ["Primary Roads", "��干道"] for x in layers_selection)
155
+ show_secondary = any(x in ["Secondary Roads", "次干道"] for x in layers_selection)
156
+ show_water = any(x in ["Water", "水域"] for x in layers_selection)
157
+ show_parks = any(x in ["Parks", "公园"] for x in layers_selection)
158
+
159
+ # Parse country
160
+ selected_country = get_country_key(country_display)
161
+
162
+ # Use dropdown selection
163
+ selected_city = city_dropdown
164
+ # If the selected_city is translated, we need the original for geocoding
165
+ # But get_coordinates typically handles common translations or we can just pass the dropdown value
166
+ # Actually, selected_city from dropdown might be "Guangzhou" or "广州"
167
+ # cmp.get_coordinates(selected_city, selected_country)
168
+ # selected_country is now standardized (e.g. "中国" or "USA")
169
+
170
+ if not selected_city:
171
+ return None, "❌ 请选择城市名称"
172
+
173
+ if not selected_country:
174
+ return None, "❌ 请选择国家"
175
+
176
+ # Determine display names based on poster_lang
177
+ lang_code = 'en' if poster_lang == "English" else 'cn'
178
+ display_city = translate(selected_city, lang_code)
179
+ display_country = translate(selected_country, lang_code)
180
+
181
+ progress(0.1, desc="正在加载主题...")
182
+
183
+ # Load theme
184
+ cmp.THEME = cmp.load_theme(theme_name)
185
+
186
+ progress(0.2, desc="正在获取坐标...")
187
+
188
+ try:
189
+ # We search coordinates using the selection
190
+ # CMP might need the "original" names if OSM behaves better with them
191
+ # For China, "广州" is better than "Guangzhou" for geopy sometimes, but geopy is usually good.
192
+ # Let's ensure we use the 'original' internal key if possible for best OSM matching
193
+ # However, for cities, we don't have a strict key map like for countries.
194
+ # We can try to translate to CN if it's a Chinese city.
195
+ search_city = selected_city
196
+ search_country = selected_country
197
+
198
+ coords = cmp.get_coordinates(search_city, search_country)
199
+ except Exception as e:
200
+ return None, f"❌ 无法找到城市坐标: {str(e)}"
201
+
202
+ progress(0.3, desc="正在生成海报...")
203
+
204
+ # Generate output filename (using English/Slugified names)
205
+ en_city = translate(selected_city, 'en')
206
+ output_file = cmp.generate_output_filename(en_city, theme_name, output_format)
207
+
208
+ try:
209
+ # Wrap the generator to yield status updates and final result
210
+ for status in cmp.create_poster(
211
+ display_city, # Pass translated names for display
212
+ display_country,
213
+ coords,
214
+ distance,
215
+ output_file,
216
+ output_format,
217
+ width=width,
218
+ height=height,
219
+ no_crop=no_crop,
220
+ show_motorway=show_motorway,
221
+ show_primary=show_primary,
222
+ show_secondary=show_secondary,
223
+ show_water=show_water,
224
+ show_parks=show_parks
225
+ ):
226
+ # Translate common status messages if possible
227
+ status_display = status
228
+ if lang_code == 'cn':
229
+ if "Downloading street network" in status: status_display = "正在下载街道数据..."
230
+ elif "Downloading features" in status: status_display = "正在下载水域和公园数据..."
231
+ elif "Rendering map" in status: status_display = "正在渲染地图..."
232
+ elif "Applying road styles" in status: status_display = "正在应用样式..."
233
+ elif "Saving to" in status: status_display = "正在保存海报..."
234
+ elif "Done" in status: status_display = "完成!"
235
+
236
+ yield None, f"⏳ {status_display}"
237
+
238
+ progress(1.0, desc="完成!")
239
+
240
+ yield output_file, f"✅ 海报生成成功!保存至: {output_file}"
241
+
242
+ except Exception as e:
243
+ import traceback
244
+ traceback.print_exc()
245
+ yield None, f"❌ 生成失败: {str(e)}"
246
+
247
+
248
+ def update_provinces(country, lang='en'):
249
+ """Update province dropdown based on country selection and language."""
250
+ # Map back to internal key for data lookup
251
+ lang_code = 'en' if lang == "English" else 'cn'
252
+ provinces = get_provinces(country, lang_code)
253
+ if provinces:
254
+ return gr.update(choices=provinces, value=provinces[0], visible=True)
255
+ return gr.update(choices=[], value=None, visible=False)
256
+
257
+
258
+ def update_cities(country, province, lang='en'):
259
+ """Update city dropdown based on province selection and language."""
260
+ lang_code = 'en' if lang == "English" else 'cn'
261
+ cities = get_cities(country, province, lang_code)
262
+ if cities:
263
+ return gr.update(choices=cities, value=cities[0])
264
+ return gr.update(choices=[], value=None)
265
+
266
+
267
+
268
+ def on_theme_change(theme_name, lang='en'):
269
+ """Update theme preview when theme changes."""
270
+ return get_theme_preview_html(theme_name, lang)
271
+
272
+
273
+ # --- Build Gradio Interface ---
274
+ def create_interface():
275
+ """Create and return the Gradio interface."""
276
+
277
+ # Get initial data (Default English)
278
+ default_lang_code = "en"
279
+ default_lang_radio = "English"
280
+ countries = get_countries(default_lang_code)
281
+ theme_choices = get_theme_choices(default_lang_code)
282
+
283
+ default_country = "China" if "China" in countries else countries[0]
284
+ default_provinces = get_provinces(default_country, default_lang_code)
285
+ default_province = default_provinces[0] if default_provinces else None
286
+ default_cities = get_cities(default_country, default_province, default_lang_code) if default_province else []
287
+ default_city = default_cities[0] if default_cities else None
288
+ default_theme = theme_choices[0][1] if theme_choices else "feature_based"
289
+ default_layers = LAYERS_EN
290
+
291
+ with gr.Blocks(
292
+ title="城市地图海报生成器",
293
+ ) as demo:
294
+
295
+ # Header
296
+ gr.HTML("""
297
+ <div class="header-title">城市地图海报生成器</div>
298
+ <div class="header-subtitle">选择任意城市,自定义主题风格,生成精美地图海报</div>
299
+ """)
300
+
301
+ with gr.Row():
302
+ # Left Column - Controls
303
+ with gr.Column(scale=1):
304
+
305
+ # City Selection Section
306
+ gr.HTML('<div class="section-title">📍 城市选择</div>')
307
+
308
+ lang_radio = gr.Radio(
309
+ choices=["English", "Chinese"],
310
+ value="English",
311
+ label="海报语言",
312
+ info="选择海报及界面的显示语言"
313
+ )
314
+
315
+ country_dropdown = gr.Dropdown(
316
+ choices=countries,
317
+ value=default_country,
318
+ label="选择国家",
319
+ interactive=True
320
+ )
321
+
322
+ province_dropdown = gr.Dropdown(
323
+ choices=default_provinces,
324
+ value=default_province,
325
+ label="选择省份/州",
326
+ interactive=True
327
+ )
328
+
329
+ city_dropdown = gr.Dropdown(
330
+ choices=default_cities,
331
+ value=default_city,
332
+ label="选择城市",
333
+ interactive=True
334
+ )
335
+
336
+ gr.HTML("<hr style='margin: 20px 0; border-color: #eee;'>")
337
+
338
+ # Theme Section
339
+ gr.HTML('<div class="section-title">🎨 主题风格</div>')
340
+
341
+ theme_dropdown = gr.Dropdown(
342
+ choices=theme_choices,
343
+ value=default_theme,
344
+ label="选择主题",
345
+ interactive=True
346
+ )
347
+
348
+ theme_preview = gr.HTML(
349
+ value=get_theme_preview_html(default_theme, default_lang_radio),
350
+ label="主题预览"
351
+ )
352
+
353
+ gr.HTML("<hr style='margin: 20px 0; border-color: #eee;'>")
354
+
355
+ # Parameters Section
356
+ gr.HTML('<div class="section-title">⚙️ 参数设置</div>')
357
+
358
+ distance_slider = gr.Slider(
359
+ minimum=4000,
360
+ maximum=30000,
361
+ value=10000,
362
+ step=1000,
363
+ label="地图范围 (米)",
364
+ info="4000-6000: 小城区 | 8000-12000: 中等城市 | 15000+: 大都市 (范围越大生成越慢)"
365
+ )
366
+
367
+ with gr.Row():
368
+ width_input = gr.Number(
369
+ value=12.0,
370
+ label="宽度 (英寸)",
371
+ minimum=6,
372
+ maximum=24
373
+ )
374
+ height_input = gr.Number(
375
+ value=16.0,
376
+ label="高度 (英寸)",
377
+ minimum=8,
378
+ maximum=32
379
+ )
380
+
381
+ format_radio = gr.Radio(
382
+ choices=["png", "svg", "pdf"],
383
+ value="png",
384
+ label="输出格式",
385
+ info="PNG: 适合打印 | SVG: 矢量图 | PDF: 文档"
386
+ )
387
+
388
+ no_crop_checkbox = gr.Checkbox(
389
+ value=False,
390
+ label="保留边距 (不裁剪)",
391
+ info="勾选后保留海报边缘背景"
392
+ )
393
+
394
+ layers_checkbox = gr.CheckboxGroup(
395
+ choices=LAYERS_EN,
396
+ value=LAYERS_EN,
397
+ label="图层显示",
398
+ info="选择需要显示的地图元素"
399
+ )
400
+
401
+ # Generate Button
402
+ generate_btn = gr.Button(
403
+ "🚀 生成海��",
404
+ variant="primary",
405
+ size="lg"
406
+ )
407
+
408
+ # Right Column - Output
409
+ with gr.Column(scale=1):
410
+ gr.HTML('<div class="section-title">🖼️ 生成结果</div>')
411
+
412
+ output_image = gr.Image(
413
+ label="海报预览",
414
+ type="filepath",
415
+ elem_classes=["output-image"],
416
+ height=600,
417
+ interactive=False
418
+ )
419
+
420
+ output_status = gr.Textbox(
421
+ label="状态",
422
+ interactive=False
423
+ )
424
+
425
+ download_btn = gr.DownloadButton(
426
+ label="📥 下载海报",
427
+ visible=False
428
+ )
429
+
430
+ # --- Event Handlers ---
431
+
432
+ # Language change -> update all dropdowns
433
+ def on_lang_change(lang, current_theme, current_layers):
434
+ lang_code = 'en' if lang == "English" else 'cn'
435
+ new_countries = get_countries(lang_code)
436
+
437
+ # Find closest match for current selections if possible
438
+ default_co = "China" if lang == "English" else "中国"
439
+ if default_co not in new_countries:
440
+ default_co = new_countries[0]
441
+
442
+ new_provinces = get_provinces(default_co, lang_code)
443
+ default_pr = new_provinces[0] if new_provinces else None
444
+
445
+ new_cities = get_cities(default_co, default_pr, lang_code) if default_pr else []
446
+ default_ci = new_cities[0] if new_cities else None
447
+
448
+ # Themes
449
+ new_theme_choices = get_theme_choices(lang_code)
450
+ new_preview = get_theme_preview_html(current_theme, lang)
451
+
452
+ # Layers
453
+ # Map current selection to keys then to new language
454
+ map_en_to_key = dict(zip(LAYERS_EN, LAYER_KEYS))
455
+ map_cn_to_key = dict(zip(LAYERS_CN, LAYER_KEYS))
456
+ map_key_to_en = dict(zip(LAYER_KEYS, LAYERS_EN))
457
+ map_key_to_cn = dict(zip(LAYER_KEYS, LAYERS_CN))
458
+
459
+ current_keys = []
460
+ for x in current_layers:
461
+ if x in map_en_to_key: current_keys.append(map_en_to_key[x])
462
+ elif x in map_cn_to_key: current_keys.append(map_cn_to_key[x])
463
+
464
+ target_map = map_key_to_en if lang == "English" else map_key_to_cn
465
+ new_layer_choices = LAYERS_EN if lang == "English" else LAYERS_CN
466
+ new_layer_values = [target_map[k] for k in current_keys if k in target_map]
467
+
468
+ return (
469
+ gr.update(choices=new_countries, value=default_co),
470
+ gr.update(choices=new_provinces, value=default_pr),
471
+ gr.update(choices=new_cities, value=default_ci),
472
+ gr.update(choices=new_theme_choices),
473
+ new_preview,
474
+ gr.update(choices=new_layer_choices, value=new_layer_values, label="Layers" if lang == "English" else "图层显示")
475
+ )
476
+
477
+ lang_radio.change(
478
+ fn=on_lang_change,
479
+ inputs=[lang_radio, theme_dropdown, layers_checkbox],
480
+ outputs=[country_dropdown, province_dropdown, city_dropdown, theme_dropdown, theme_preview, layers_checkbox]
481
+ )
482
+
483
+ # Country change -> update provinces
484
+ country_dropdown.change(
485
+ fn=update_provinces,
486
+ inputs=[country_dropdown, lang_radio],
487
+ outputs=[province_dropdown]
488
+ )
489
+
490
+ # Province change -> update cities
491
+ province_dropdown.change(
492
+ fn=update_cities,
493
+ inputs=[country_dropdown, province_dropdown, lang_radio],
494
+ outputs=[city_dropdown]
495
+ )
496
+
497
+ # Theme change -> update preview
498
+ theme_dropdown.change(
499
+ fn=on_theme_change,
500
+ inputs=[theme_dropdown, lang_radio],
501
+ outputs=[theme_preview]
502
+ )
503
+
504
+ # Generate button click
505
+ def on_generate_complete(filepath, status):
506
+ """Handle generate completion - show download button if successful."""
507
+ if filepath and os.path.exists(filepath):
508
+ return filepath, status, gr.update(visible=True, value=filepath)
509
+ return filepath, status, gr.update(visible=False)
510
+
511
+ generate_btn.click(
512
+ fn=generate_poster,
513
+ inputs=[
514
+ country_dropdown,
515
+ province_dropdown,
516
+ city_dropdown,
517
+ theme_dropdown,
518
+ distance_slider,
519
+ width_input,
520
+ height_input,
521
+ format_radio,
522
+ no_crop_checkbox,
523
+ lang_radio,
524
+ layers_checkbox
525
+ ],
526
+ outputs=[output_image, output_status]
527
+ ).then(
528
+ fn=on_generate_complete,
529
+ inputs=[output_image, output_status],
530
+ outputs=[output_image, output_status, download_btn]
531
+ )
532
+
533
+ # Footer
534
+ gr.HTML("""
535
+ <div style="text-align: center; margin-top: 24px; padding: 12px; color: #888; font-size: 12px;">
536
+ <p>数据来源: © OpenStreetMap contributors | 地理编码: Nominatim</p>
537
+ <p>提示: 生成大范围地图可能需要较长时间,请耐心等待</p>
538
+ </div>
539
+ """)
540
+
541
+ return demo
542
+
543
+
544
+ # --- Main Entry ---
545
+ if __name__ == "__main__":
546
+ demo = create_interface()
547
+ demo.launch(
548
+ server_name="0.0.0.0",
549
+ server_port=7860,
550
+ share=False,
551
+ show_error=True,
552
+ theme=gr.themes.Default(),
553
+ css="""
554
+ .header-title {
555
+ text-align: center;
556
+ font-size: 2em;
557
+ font-weight: bold;
558
+ margin-bottom: 0.5em;
559
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
560
+ -webkit-background-clip: text;
561
+ -webkit-text-fill-color: transparent;
562
+ background-clip: text;
563
+ }
564
+ .header-subtitle {
565
+ text-align: center;
566
+ color: #666;
567
+ margin-bottom: 1.5em;
568
+ }
569
+ .section-title {
570
+ font-weight: 600;
571
+ font-size: 1.1em;
572
+ margin-bottom: 0.5em;
573
+ color: #333;
574
+ }
575
+ .output-image {
576
+ border-radius: 12px;
577
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
578
+ }
579
+ """
580
+ )
cities_data.py ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Cities database with hierarchical structure for cascading selection.
4
+ Format: Country -> Province/State -> Cities
5
+ """
6
+
7
+ CITIES = {
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
+ "USA": {
56
+ "California": [
57
+ "Los Angeles",
58
+ "San Francisco",
59
+ "San Diego",
60
+ "San Jose",
61
+ "Sacramento",
62
+ "Oakland",
63
+ "Fresno",
64
+ ],
65
+ "New York": ["New York City", "Buffalo", "Rochester", "Albany", "Syracuse"],
66
+ "Texas": [
67
+ "Houston",
68
+ "Dallas",
69
+ "Austin",
70
+ "San Antonio",
71
+ "Fort Worth",
72
+ "El Paso",
73
+ ],
74
+ "Florida": ["Miami", "Orlando", "Tampa", "Jacksonville", "Fort Lauderdale"],
75
+ "Illinois": ["Chicago", "Aurora", "Naperville", "Rockford"],
76
+ "Pennsylvania": ["Philadelphia", "Pittsburgh", "Harrisburg"],
77
+ "Arizona": ["Phoenix", "Tucson", "Mesa", "Scottsdale"],
78
+ "Nevada": ["Las Vegas", "Reno", "Henderson"],
79
+ "Washington": ["Seattle", "Tacoma", "Spokane", "Bellevue"],
80
+ "Massachusetts": ["Boston", "Cambridge", "Worcester"],
81
+ "Colorado": ["Denver", "Colorado Springs", "Aurora", "Boulder"],
82
+ "Georgia": ["Atlanta", "Savannah", "Augusta"],
83
+ "North Carolina": ["Charlotte", "Raleigh", "Durham"],
84
+ "Michigan": ["Detroit", "Grand Rapids", "Ann Arbor"],
85
+ "Oregon": ["Portland", "Salem", "Eugene"],
86
+ "District of Columbia": ["Washington"],
87
+ "Hawaii": ["Honolulu"],
88
+ },
89
+ "Japan": {
90
+ "関東": ["Tokyo", "Yokohama", "Kawasaki", "Saitama", "Chiba"],
91
+ "関西": ["Osaka", "Kyoto", "Kobe", "Nara"],
92
+ "中部": ["Nagoya", "Kanazawa", "Shizuoka"],
93
+ "北海道": ["Sapporo", "Hakodate", "Asahikawa"],
94
+ "九州": ["Fukuoka", "Nagasaki", "Kumamoto", "Kagoshima"],
95
+ "東北": ["Sendai", "Aomori", "Akita"],
96
+ "中国": ["Hiroshima", "Okayama"],
97
+ "四国": ["Matsuyama", "Takamatsu"],
98
+ "沖縄": ["Naha", "Okinawa"],
99
+ },
100
+ "UK": {
101
+ "England": [
102
+ "London",
103
+ "Manchester",
104
+ "Birmingham",
105
+ "Liverpool",
106
+ "Leeds",
107
+ "Bristol",
108
+ "Sheffield",
109
+ "Newcastle",
110
+ "Nottingham",
111
+ "Cambridge",
112
+ "Oxford",
113
+ ],
114
+ "Scotland": ["Edinburgh", "Glasgow", "Aberdeen", "Dundee"],
115
+ "Wales": ["Cardiff", "Swansea", "Newport"],
116
+ "Northern Ireland": ["Belfast", "Londonderry"],
117
+ },
118
+ "France": {
119
+ "Île-de-France": ["Paris"],
120
+ "Provence-Alpes-Côte d'Azur": ["Marseille", "Nice", "Cannes", "Toulon"],
121
+ "Auvergne-Rhône-Alpes": ["Lyon", "Grenoble", "Saint-Étienne"],
122
+ "Nouvelle-Aquitaine": ["Bordeaux", "Limoges"],
123
+ "Occitanie": ["Toulouse", "Montpellier", "Nîmes"],
124
+ "Hauts-de-France": ["Lille", "Amiens"],
125
+ "Grand Est": ["Strasbourg", "Reims", "Nancy", "Metz"],
126
+ "Normandie": ["Rouen", "Le Havre", "Caen"],
127
+ "Bretagne": ["Rennes", "Brest", "Nantes"],
128
+ },
129
+ "Germany": {
130
+ "Bayern": ["Munich", "Nuremberg", "Augsburg"],
131
+ "Berlin": ["Berlin"],
132
+ "Hamburg": ["Hamburg"],
133
+ "Hessen": ["Frankfurt", "Wiesbaden"],
134
+ "Baden-Württemberg": ["Stuttgart", "Heidelberg", "Freiburg", "Karlsruhe"],
135
+ "Nordrhein-Westfalen": ["Cologne", "Düsseldorf", "Dortmund", "Essen", "Bonn"],
136
+ "Niedersachsen": ["Hanover", "Braunschweig", "Oldenburg"],
137
+ "Sachsen": ["Dresden", "Leipzig"],
138
+ "Brandenburg": ["Potsdam"],
139
+ },
140
+ "Italy": {
141
+ "Lazio": ["Rome"],
142
+ "Lombardia": ["Milan", "Bergamo", "Brescia"],
143
+ "Veneto": ["Venice", "Verona", "Padua"],
144
+ "Toscana": ["Florence", "Pisa", "Siena"],
145
+ "Campania": ["Naples", "Salerno"],
146
+ "Piemonte": ["Turin", "Genoa"],
147
+ "Emilia-Romagna": ["Bologna", "Parma", "Modena"],
148
+ "Sicilia": ["Palermo", "Catania", "Syracuse"],
149
+ },
150
+ "Spain": {
151
+ "Comunidad de Madrid": ["Madrid"],
152
+ "Cataluña": ["Barcelona", "Tarragona", "Girona"],
153
+ "Andalucía": ["Seville", "Granada", "Málaga", "Córdoba"],
154
+ "Comunidad Valenciana": ["Valencia", "Alicante"],
155
+ "País Vasco": ["Bilbao", "San Sebastián"],
156
+ "Galicia": ["Santiago de Compostela", "A Coruña", "Vigo"],
157
+ "Islas Baleares": ["Palma de Mallorca"],
158
+ "Islas Canarias": ["Las Palmas", "Santa Cruz de Tenerife"],
159
+ },
160
+ "Australia": {
161
+ "New South Wales": ["Sydney", "Newcastle", "Wollongong"],
162
+ "Victoria": ["Melbourne", "Geelong"],
163
+ "Queensland": ["Brisbane", "Gold Coast", "Cairns"],
164
+ "Western Australia": ["Perth", "Fremantle"],
165
+ "South Australia": ["Adelaide"],
166
+ "Tasmania": ["Hobart"],
167
+ "Australian Capital Territory": ["Canberra"],
168
+ "Northern Territory": ["Darwin"],
169
+ },
170
+ "Canada": {
171
+ "Ontario": ["Toronto", "Ottawa", "Hamilton", "Mississauga"],
172
+ "Quebec": ["Montreal", "Quebec City", "Laval"],
173
+ "British Columbia": ["Vancouver", "Victoria", "Surrey"],
174
+ "Alberta": ["Calgary", "Edmonton"],
175
+ "Manitoba": ["Winnipeg"],
176
+ "Saskatchewan": ["Saskatoon", "Regina"],
177
+ "Nova Scotia": ["Halifax"],
178
+ },
179
+ "South Korea": {
180
+ "서울특별시": ["Seoul"],
181
+ "부산광역시": ["Busan"],
182
+ "경기도": ["Incheon", "Suwon", "Seongnam"],
183
+ "대구광역시": ["Daegu"],
184
+ "대전광역시": ["Daejeon"],
185
+ "광주광역시": ["Gwangju"],
186
+ "제주특별자치도": ["Jeju"],
187
+ },
188
+ "Singapore": {
189
+ "Singapore": ["Singapore"],
190
+ },
191
+ "India": {
192
+ "Maharashtra": ["Mumbai", "Pune", "Nagpur"],
193
+ "Delhi": ["New Delhi"],
194
+ "Karnataka": ["Bangalore", "Mysore"],
195
+ "Tamil Nadu": ["Chennai", "Coimbatore", "Madurai"],
196
+ "West Bengal": ["Kolkata"],
197
+ "Gujarat": ["Ahmedabad", "Surat", "Vadodara"],
198
+ "Telangana": ["Hyderabad"],
199
+ "Kerala": ["Kochi", "Trivandrum"],
200
+ "Rajasthan": ["Jaipur", "Jodhpur", "Udaipur"],
201
+ "Uttar Pradesh": ["Lucknow", "Agra", "Varanasi"],
202
+ },
203
+ "Russia": {
204
+ "Центральный": ["Moscow"],
205
+ "Северо-Западный": ["Saint Petersburg", "Kaliningrad"],
206
+ "Южный": ["Sochi", "Rostov-on-Don", "Krasnodar"],
207
+ "Приволжский": ["Kazan", "Nizhny Novgorod", "Samara"],
208
+ "Уральский": ["Yekaterinburg", "Chelyabinsk"],
209
+ "Сибирский": ["Novosibirsk", "Krasnoyarsk", "Irkutsk"],
210
+ "Дальневосточный": ["Vladivostok", "Khabarovsk"],
211
+ },
212
+ "Brazil": {
213
+ "São Paulo": ["São Paulo", "Campinas", "Santos"],
214
+ "Rio de Janeiro": ["Rio de Janeiro", "Niterói"],
215
+ "Minas Gerais": ["Belo Horizonte"],
216
+ "Bahia": ["Salvador"],
217
+ "Rio Grande do Sul": ["Porto Alegre"],
218
+ "Paraná": ["Curitiba"],
219
+ "Distrito Federal": ["Brasília"],
220
+ "Ceará": ["Fortaleza"],
221
+ "Pernambuco": ["Recife"],
222
+ "Amazonas": ["Manaus"],
223
+ },
224
+ "Mexico": {
225
+ "Ciudad de México": ["Mexico City"],
226
+ "Jalisco": ["Guadalajara"],
227
+ "Nuevo León": ["Monterrey"],
228
+ "Quintana Roo": ["Cancún", "Playa del Carmen"],
229
+ "Baja California": ["Tijuana", "Ensenada"],
230
+ "Yucatán": ["Mérida"],
231
+ "Puebla": ["Puebla"],
232
+ "Guanajuato": ["León", "Guanajuato"],
233
+ },
234
+ "Netherlands": {
235
+ "Noord-Holland": ["Amsterdam", "Haarlem"],
236
+ "Zuid-Holland": ["Rotterdam", "The Hague", "Leiden"],
237
+ "Utrecht": ["Utrecht"],
238
+ "Noord-Brabant": ["Eindhoven", "Tilburg", "'s-Hertogenbosch"],
239
+ "Gelderland": ["Arnhem", "Nijmegen"],
240
+ "Limburg": ["Maastricht"],
241
+ },
242
+ "Belgium": {
243
+ "Brussels-Capital": ["Brussels"],
244
+ "Flanders": ["Antwerp", "Ghent", "Bruges"],
245
+ "Wallonia": ["Liège", "Charleroi", "Namur"],
246
+ },
247
+ "Switzerland": {
248
+ "Zürich": ["Zurich"],
249
+ "Bern": ["Bern"],
250
+ "Geneva": ["Geneva"],
251
+ "Vaud": ["Lausanne"],
252
+ "Basel-Stadt": ["Basel"],
253
+ "Lucerne": ["Lucerne"],
254
+ },
255
+ "Austria": {
256
+ "Wien": ["Vienna"],
257
+ "Salzburg": ["Salzburg"],
258
+ "Tirol": ["Innsbruck"],
259
+ "Steiermark": ["Graz"],
260
+ "Oberösterreich": ["Linz"],
261
+ },
262
+ "Portugal": {
263
+ "Lisboa": ["Lisbon"],
264
+ "Porto": ["Porto"],
265
+ "Faro": ["Faro", "Albufeira"],
266
+ "Coimbra": ["Coimbra"],
267
+ },
268
+ "Greece": {
269
+ "Attica": ["Athens", "Piraeus"],
270
+ "Central Macedonia": ["Thessaloniki"],
271
+ "Crete": ["Heraklion"],
272
+ "South Aegean": ["Rhodes", "Mykonos", "Santorini"],
273
+ },
274
+ "Turkey": {
275
+ "Istanbul": ["Istanbul"],
276
+ "Ankara": ["Ankara"],
277
+ "Izmir": ["Izmir"],
278
+ "Antalya": ["Antalya"],
279
+ "Bursa": ["Bursa"],
280
+ "Cappadocia": ["Nevşehir", "Göreme"],
281
+ },
282
+ "UAE": {
283
+ "Dubai": ["Dubai"],
284
+ "Abu Dhabi": ["Abu Dhabi"],
285
+ "Sharjah": ["Sharjah"],
286
+ "Ras Al Khaimah": ["Ras Al Khaimah"],
287
+ },
288
+ "Thailand": {
289
+ "Bangkok": ["Bangkok"],
290
+ "Chiang Mai": ["Chiang Mai"],
291
+ "Phuket": ["Phuket"],
292
+ "Chonburi": ["Pattaya"],
293
+ "Krabi": ["Krabi"],
294
+ },
295
+ "Vietnam": {
296
+ "Hà Nội": ["Hanoi"],
297
+ "Hồ Chí Minh": ["Ho Chi Minh City"],
298
+ "Đà Nẵng": ["Da Nang"],
299
+ "Khánh Hòa": ["Nha Trang"],
300
+ "Quảng Ninh": ["Ha Long"],
301
+ },
302
+ "Indonesia": {
303
+ "DKI Jakarta": ["Jakarta"],
304
+ "Bali": ["Bali", "Denpasar", "Ubud"],
305
+ "Jawa Timur": ["Surabaya"],
306
+ "Jawa Barat": ["Bandung"],
307
+ "DI Yogyakarta": ["Yogyakarta"],
308
+ },
309
+ "Malaysia": {
310
+ "Kuala Lumpur": ["Kuala Lumpur"],
311
+ "Penang": ["George Town"],
312
+ "Selangor": ["Petaling Jaya", "Shah Alam"],
313
+ "Johor": ["Johor Bahru"],
314
+ "Sabah": ["Kota Kinabalu"],
315
+ "Sarawak": ["Kuching"],
316
+ },
317
+ "Philippines": {
318
+ "Metro Manila": ["Manila", "Makati", "Quezon City"],
319
+ "Cebu": ["Cebu City"],
320
+ "Davao": ["Davao City"],
321
+ "Palawan": ["Puerto Princesa"],
322
+ },
323
+ "Egypt": {
324
+ "Cairo Governorate": ["Cairo"],
325
+ "Alexandria Governorate": ["Alexandria"],
326
+ "Giza Governorate": ["Giza"],
327
+ "Luxor Governorate": ["Luxor"],
328
+ "Red Sea Governorate": ["Hurghada", "Sharm El Sheikh"],
329
+ },
330
+ "South Africa": {
331
+ "Gauteng": ["Johannesburg", "Pretoria"],
332
+ "Western Cape": ["Cape Town"],
333
+ "KwaZulu-Natal": ["Durban"],
334
+ "Eastern Cape": ["Port Elizabeth"],
335
+ },
336
+ "Morocco": {
337
+ "Casablanca-Settat": ["Casablanca"],
338
+ "Rabat-Salé-Kénitra": ["Rabat"],
339
+ "Marrakech-Safi": ["Marrakech", "Essaouira"],
340
+ "Fès-Meknès": ["Fes", "Meknes"],
341
+ "Tanger-Tétouan-Al Hoceïma": ["Tangier", "Chefchaouen"],
342
+ },
343
+ "Argentina": {
344
+ "Buenos Aires": ["Buenos Aires", "La Plata"],
345
+ "Córdoba": ["Córdoba"],
346
+ "Mendoza": ["Mendoza"],
347
+ "Santa Fe": ["Rosario"],
348
+ "Tierra del Fuego": ["Ushuaia"],
349
+ },
350
+ "Chile": {
351
+ "Región Metropolitana": ["Santiago"],
352
+ "Valparaíso": ["Valparaíso", "Viña del Mar"],
353
+ "Biobío": ["Concepción"],
354
+ "Los Lagos": ["Puerto Montt"],
355
+ "Magallanes": ["Punta Arenas"],
356
+ },
357
+ "Colombia": {
358
+ "Bogotá D.C.": ["Bogotá"],
359
+ "Antioquia": ["Medellín"],
360
+ "Valle del Cauca": ["Cali"],
361
+ "Atlántico": ["Barranquilla"],
362
+ "Bolívar": ["Cartagena"],
363
+ },
364
+ "Peru": {
365
+ "Lima": ["Lima"],
366
+ "Cusco": ["Cusco"],
367
+ "Arequipa": ["Arequipa"],
368
+ "La Libertad": ["Trujillo"],
369
+ },
370
+ "New Zealand": {
371
+ "Auckland": ["Auckland"],
372
+ "Wellington": ["Wellington"],
373
+ "Canterbury": ["Christchurch"],
374
+ "Otago": ["Dunedin", "Queenstown"],
375
+ },
376
+ "Ireland": {
377
+ "Leinster": ["Dublin"],
378
+ "Munster": ["Cork", "Limerick"],
379
+ "Connacht": ["Galway"],
380
+ },
381
+ "Sweden": {
382
+ "Stockholms län": ["Stockholm"],
383
+ "Västra Götalands län": ["Gothenburg"],
384
+ "Skåne län": ["Malmö"],
385
+ "Uppsala län": ["Uppsala"],
386
+ },
387
+ "Norway": {
388
+ "Oslo": ["Oslo"],
389
+ "Vestland": ["Bergen"],
390
+ "Troms og Finnmark": ["Tromsø"],
391
+ "Trøndelag": ["Trondheim"],
392
+ },
393
+ "Denmark": {
394
+ "Hovedstaden": ["Copenhagen"],
395
+ "Midtjylland": ["Aarhus"],
396
+ "Syddanmark": ["Odense"],
397
+ },
398
+ "Finland": {
399
+ "Uusimaa": ["Helsinki", "Espoo"],
400
+ "Pirkanmaa": ["Tampere"],
401
+ "Southwest Finland": ["Turku"],
402
+ "Lapland": ["Rovaniemi"],
403
+ },
404
+ "Poland": {
405
+ "Mazowieckie": ["Warsaw"],
406
+ "Małopolskie": ["Kraków"],
407
+ "Wielkopolskie": ["Poznań"],
408
+ "Dolnośląskie": ["Wrocław"],
409
+ "Pomorskie": ["Gdańsk"],
410
+ },
411
+ "Czech Republic": {
412
+ "Praha": ["Prague"],
413
+ "Jihomoravský": ["Brno"],
414
+ "Moravskoslezský": ["Ostrava"],
415
+ "Plzeňský": ["Plzeň"],
416
+ },
417
+ "Hungary": {
418
+ "Budapest": ["Budapest"],
419
+ "Pest": ["Szentendre"],
420
+ "Baranya": ["Pécs"],
421
+ "Hajdú-Bihar": ["Debrecen"],
422
+ },
423
+ "Israel": {
424
+ "Tel Aviv District": ["Tel Aviv", "Jaffa"],
425
+ "Jerusalem District": ["Jerusalem"],
426
+ "Haifa District": ["Haifa"],
427
+ "Southern District": ["Eilat"],
428
+ },
429
+ "Saudi Arabia": {
430
+ "Riyadh": ["Riyadh"],
431
+ "Makkah": ["Mecca", "Jeddah"],
432
+ "Eastern Province": ["Dammam", "Dhahran"],
433
+ "Medina": ["Medina"],
434
+ },
435
+ "Qatar": {
436
+ "Doha": ["Doha"],
437
+ },
438
+ "Kenya": {
439
+ "Nairobi": ["Nairobi"],
440
+ "Mombasa": ["Mombasa"],
441
+ "Nakuru": ["Nakuru"],
442
+ },
443
+ "Nigeria": {
444
+ "Lagos": ["Lagos"],
445
+ "Abuja": ["Abuja"],
446
+ "Kano": ["Kano"],
447
+ },
448
+ "Pakistan": {
449
+ "Punjab": ["Lahore", "Faisalabad"],
450
+ "Sindh": ["Karachi"],
451
+ "Islamabad": ["Islamabad"],
452
+ "Khyber Pakhtunkhwa": ["Peshawar"],
453
+ },
454
+ "Bangladesh": {
455
+ "Dhaka Division": ["Dhaka"],
456
+ "Chittagong Division": ["Chittagong"],
457
+ "Sylhet Division": ["Sylhet"],
458
+ },
459
+ "Sri Lanka": {
460
+ "Western Province": ["Colombo"],
461
+ "Central Province": ["Kandy"],
462
+ "Southern Province": ["Galle"],
463
+ },
464
+ "Nepal": {
465
+ "Bagmati": ["Kathmandu"],
466
+ "Gandaki": ["Pokhara"],
467
+ },
468
+ }
469
+
470
+ # Translation layer for dual-language support
471
+ CN_TO_EN = {
472
+ # Countries (CN -> EN)
473
+ "中国": "China",
474
+ "美国": "United States",
475
+ "日本": "Japan",
476
+ "英国": "United Kingdom",
477
+ "法国": "France",
478
+ "德国": "Germany",
479
+ "意大利": "Italy",
480
+ "西班牙": "Spain",
481
+ "澳大利亚": "Australia",
482
+ "加拿大": "Canada",
483
+ "韩国": "South Korea",
484
+ "新加坡": "Singapore",
485
+ "印度": "India",
486
+ "俄罗斯": "Russia",
487
+ "巴西": "Brazil",
488
+ "墨西哥": "Mexico",
489
+ "荷兰": "Netherlands",
490
+ "比利时": "Belgium",
491
+ "瑞士": "Switzerland",
492
+ "奥地利": "Austria",
493
+ "葡萄牙": "Portugal",
494
+ "希腊": "Greece",
495
+ "土耳其": "Turkey",
496
+ "阿联酋": "United Arab Emirates",
497
+ "泰国": "Thailand",
498
+ "越南": "Vietnam",
499
+ "印度尼西亚": "Indonesia",
500
+ "马来西亚": "Malaysia",
501
+ "菲律宾": "Philippines",
502
+ "埃及": "Egypt",
503
+ "南非": "South Africa",
504
+ "摩洛哥": "Morocco",
505
+ "阿根廷": "Argentina",
506
+ "智利": "Chile",
507
+ "哥伦比亚": "Colombia",
508
+ "秘鲁": "Peru",
509
+ "新西兰": "New Zealand",
510
+ "爱尔兰": "Ireland",
511
+ "瑞典": "Sweden",
512
+ "挪威": "Norway",
513
+ "丹麦": "Denmark",
514
+ "芬兰": "Finland",
515
+ "波兰": "Poland",
516
+ "捷克": "Czech Republic",
517
+ "匈牙利": "Hungary",
518
+ "以色列": "Israel",
519
+ "沙特阿拉伯": "Saudi Arabia",
520
+ "卡塔尔": "Qatar",
521
+ "肯尼亚": "Kenya",
522
+ "尼日利亚": "Nigeria",
523
+ "巴基斯坦": "Pakistan",
524
+ "孟加拉国": "Bangladesh",
525
+ "斯里兰卡": "Sri Lanka",
526
+ "尼泊尔": "Nepal",
527
+ # Chinese Provinces (CN -> EN)
528
+ "北京": "Beijing",
529
+ "上海": "Shanghai",
530
+ "天津": "Tianjin",
531
+ "重庆": "Chongqing",
532
+ "广东": "Guangdong",
533
+ "浙江": "Zhejiang",
534
+ "江苏": "Jiangsu",
535
+ "山东": "Shandong",
536
+ "四川": "Sichuan",
537
+ "湖北": "Hubei",
538
+ "湖南": "Hunan",
539
+ "河南": "Henan",
540
+ "河北": "Hebei",
541
+ "福建": "Fujian",
542
+ "安徽": "Anhui",
543
+ "江西": "Jiangxi",
544
+ "陕西": "Shaanxi",
545
+ "山西": "Shanxi",
546
+ "辽宁": "Liaoning",
547
+ "吉林": "Jilin",
548
+ "黑龙江": "Heilongjiang",
549
+ "云南": "Yunnan",
550
+ "贵州": "Guizhou",
551
+ "甘肃": "Gansu",
552
+ "海南": "Hainan",
553
+ "广西": "Guangxi",
554
+ "内蒙古": "Inner Mongolia",
555
+ "新疆": "Xinjiang",
556
+ "西藏": "Tibet",
557
+ "宁夏": "Ningxia",
558
+ "青海": "Qinghai",
559
+ "香港": "Hong Kong",
560
+ "澳门": "Macau",
561
+ "台湾": "Taiwan",
562
+ # Chinese Cities (CN -> EN)
563
+ "广州": "Guangzhou",
564
+ "深圳": "Shenzhen",
565
+ "东莞": "Dongguan",
566
+ "佛山": "Foshan",
567
+ "珠海": "Zhuhai",
568
+ "惠州": "Huizhou",
569
+ "中山": "Zhongshan",
570
+ "汕头": "Shantou",
571
+ "湛江": "Zhanjiang",
572
+ "江门": "Jiangmen",
573
+ "梧州": "Wuzhou",
574
+ "杭州": "Hangzhou",
575
+ "宁波": "Ningbo",
576
+ "温州": "Wenzhou",
577
+ "绍���": "Shaoxing",
578
+ "嘉兴": "Jiaxing",
579
+ "金华": "Jinhua",
580
+ "台州": "Taizhou",
581
+ "湖州": "Huzhou",
582
+ "南京": "Nanjing",
583
+ "苏州": "Suzhou",
584
+ "无锡": "Wuxi",
585
+ "常州": "Changzhou",
586
+ "南通": "Nantong",
587
+ "徐州": "Xuzhou",
588
+ "扬州": "Yangzhou",
589
+ "镇江": "Zhenjiang",
590
+ "济南": "Jinan",
591
+ "青岛": "Qingdao",
592
+ "烟台": "Yantai",
593
+ "威海": "Weihai",
594
+ "潍坊": "Weifang",
595
+ "临沂": "Linyi",
596
+ "济宁": "Jining",
597
+ "淄博": "Zibo",
598
+ "成都": "Chengdu",
599
+ "绵阳": "Mianyang",
600
+ "德阳": "Deyang",
601
+ "宜宾": "Yibin",
602
+ "泸州": "Luzhou",
603
+ "南充": "Nanchong",
604
+ "乐山": "Leshan",
605
+ "武汉": "Wuhan",
606
+ "宜昌": "Yichang",
607
+ "襄阳": "Xiangyang",
608
+ "荆州": "Jingzhou",
609
+ "黄石": "Huangshi",
610
+ "十堰": "Shiyan",
611
+ "长沙": "Changsha",
612
+ "株洲": "Zhuzhou",
613
+ "湘潭": "Xiangtan",
614
+ "衡阳": "Hengyang",
615
+ "岳阳": "Yueyang",
616
+ "常德": "Changde",
617
+ "郑州": "Zhengzhou",
618
+ "洛阳": "Luoyang",
619
+ "开封": "Kaifeng",
620
+ "新乡": "Xinxiang",
621
+ "安阳": "Anyang",
622
+ "焦作": "Jiaozuo",
623
+ "石家庄": "Shijiazhuang",
624
+ "唐山": "Tangshan",
625
+ "秦皇岛": "Qinhuangdao",
626
+ "邯郸": "Handan",
627
+ "保定": "Baoding",
628
+ "沧州": "Cangzhou",
629
+ "福州": "Fuzhou",
630
+ "厦门": "Xiamen",
631
+ "泉州": "Quanzhou",
632
+ "漳州": "Zhangzhou",
633
+ "莆田": "Putian",
634
+ "龙岩": "Longyan",
635
+ "合肥": "Hefei",
636
+ "芜湖": "Wuhu",
637
+ "蚌埠": "Bengbu",
638
+ "马鞍山": "Ma'anshan",
639
+ "安庆": "Anqing",
640
+ "黄山": "Huangshan",
641
+ "南昌": "Nanchang",
642
+ "九江": "Jiujiang",
643
+ "景德镇": "Jingdezhen",
644
+ "赣州": "Ganzhou",
645
+ "上饶": "Shangrao",
646
+ "吉安": "Ji'an",
647
+ "西安": "Xi'an",
648
+ "咸阳": "Xianyang",
649
+ "宝鸡": "Baoji",
650
+ "延安": "Yan'an",
651
+ "榆林": "Yulin",
652
+ "汉中": "Hanzhong",
653
+ "太原": "Taiyuan",
654
+ "大同": "Datong",
655
+ "临汾": "Linfen",
656
+ "运城": "Yuncheng",
657
+ "晋中": "Jinzhong",
658
+ "长治": "Changzhi",
659
+ "沈阳": "Shenyang",
660
+ "大连": "Dalian",
661
+ "鞍山": "Anshan",
662
+ "抚顺": "Fushun",
663
+ "本溪": "Benxi",
664
+ "营口": "Yingkou",
665
+ "长春": "Changchun",
666
+ "吉林": "Jilin",
667
+ "四平": "Siping",
668
+ "通化": "Tonghua",
669
+ "延边": "Yanbian",
670
+ "哈尔滨": "Harbin",
671
+ "齐齐哈尔": "Qiqihar",
672
+ "牡丹江": "Mudanjiang",
673
+ "佳木斯": "Jiamusi",
674
+ "大庆": "Daqing",
675
+ "昆明": "Kunming",
676
+ "大理": "Dali",
677
+ "丽江": "Lijiang",
678
+ "西双版纳": "Xishuangbanna",
679
+ "曲靖": "Qujing",
680
+ "贵阳": "Guiyang",
681
+ "遵义": "Zunyi",
682
+ "安顺": "Anshun",
683
+ "六盘水": "Liupanshui",
684
+ "毕节": "Bijie",
685
+ "兰州": "Lanzhou",
686
+ "天水": "Tianshui",
687
+ "嘉峪关": "Jiayuguan",
688
+ "酒泉": "Jiuquan",
689
+ "张掖": "Zhangye",
690
+ "海口": "Haikou",
691
+ "三亚": "Sanya",
692
+ "儋州": "Danzhou",
693
+ "琼海": "Qionghai",
694
+ "南宁": "Nanning",
695
+ "桂林": "Guilin",
696
+ "柳州": "Liuzhou",
697
+ "北海": "Beihai",
698
+ "玉林": "Yulin",
699
+ "呼和浩特": "Hohhot",
700
+ "包头": "Baotou",
701
+ "鄂尔多斯": "Ordos",
702
+ "赤峰": "Chifeng",
703
+ "呼伦贝尔": "Hulunbuir",
704
+ "乌鲁木齐": "Urumqi",
705
+ "喀什": "Kashgar",
706
+ "吐鲁番": "Turpan",
707
+ "阿克苏": "Aksu",
708
+ "伊宁": "Yining",
709
+ "拉萨": "Lhasa",
710
+ "日喀则": "Shigatse",
711
+ "林芝": "Nyingchi",
712
+ "昌都": "Qamdo",
713
+ "银川": "Yinchuan",
714
+ "石嘴山": "Shizuishan",
715
+ "吴忠": "Wuzhong",
716
+ "固原": "Guyuan",
717
+ "西宁": "Xining",
718
+ "格尔木": "Golmud",
719
+ "玉树": "Yushu",
720
+ "海东": "Haidong",
721
+ "台北": "Taipei",
722
+ "高雄": "Kaohsiung",
723
+ "台中": "Taichung",
724
+ "台南": "Tainan",
725
+ "新竹": "Hsinchu",
726
+ "基隆": "Keelung",
727
+ # Theme Names - MOVED TO EN_TO_CN
728
+ }
729
+
730
+ # English name to Chinese (for when UI/Poster is in Chinese)
731
+ EN_TO_CN = {
732
+ "China": "中国",
733
+ "USA": "美国",
734
+ "United States": "美国",
735
+ "Japan": "日本",
736
+ "UK": "英国",
737
+ "United Kingdom": "英国",
738
+ "France": "法国",
739
+ "Germany": "德国",
740
+ "Italy": "意大利",
741
+ "Spain": "西班牙",
742
+ "Australia": "澳大利亚",
743
+ "Canada": "加拿大",
744
+ "South Korea": "韩国",
745
+ "Singapore": "新加坡",
746
+ "India": "印度",
747
+ "Russia": "俄罗斯",
748
+ "Brazil": "巴西",
749
+ "Mexico": "墨西哥",
750
+ "Netherlands": "荷兰",
751
+ "Belgium": "比利时",
752
+ "Switzerland": "瑞士",
753
+ "Austria": "奥地利",
754
+ "Portugal": "葡萄牙",
755
+ "Greece": "希腊",
756
+ "Turkey": "土耳其",
757
+ "UAE": "阿联酋",
758
+ "United Arab Emirates": "阿联酋",
759
+ "Thailand": "泰国",
760
+ "Vietnam": "越南",
761
+ "Indonesia": "印度尼西亚",
762
+ "Malaysia": "马来西亚",
763
+ "Philippines": "菲律宾",
764
+ "Egypt": "埃及",
765
+ "South Africa": "南非",
766
+ "Morocco": "摩洛哥",
767
+ "Argentina": "阿根廷",
768
+ "Chile": "智利",
769
+ "Colombia": "哥伦比亚",
770
+ "Peru": "秘鲁",
771
+ "New Zealand": "新西兰",
772
+ "Ireland": "爱尔兰",
773
+ "Sweden": "瑞典",
774
+ "Norway": "挪威",
775
+ "Denmark": "丹麦",
776
+ "Finland": "芬兰",
777
+ "Poland": "波兰",
778
+ "Czech Republic": "捷克",
779
+ "Hungary": "匈牙利",
780
+ "Israel": "以色列",
781
+ "Saudi Arabia": "沙特阿拉伯",
782
+ "Qatar": "卡塔尔",
783
+ "Kenya": "肯尼亚",
784
+ "Nigeria": "尼日利亚",
785
+ "Pakistan": "巴基斯坦",
786
+ "Bangladesh": "孟加拉国",
787
+ "Sri Lanka": "斯里兰卡",
788
+ "Nepal": "尼泊尔",
789
+ # Common Cities
790
+ "New York City": "纽约",
791
+ "London": "伦敦",
792
+ "Paris": "巴黎",
793
+ "Tokyo": "东京",
794
+ "Guangzhou": "广州",
795
+ "Shenzhen": "深圳",
796
+ "Beijing": "北京",
797
+ "Shanghai": "上海",
798
+ # Theme Names
799
+ "Feature-Based Shading": "特征着色",
800
+ "Japanese Ink": "日式水墨",
801
+ "Midnight Blue": "午夜蓝",
802
+ "Terracotta": "陶土色",
803
+ "Autumn": "秋日",
804
+ "Blueprint": "蓝图",
805
+ "Contrast Zones": "高对比度",
806
+ "Copper Patina": "铜绿",
807
+ "Forest": "森林",
808
+ "Gradient Roads": "渐变道路",
809
+ "Monochrome Blue": "单色蓝",
810
+ "Neon Cyberpunk": "霓虹赛博",
811
+ "Noir": "诺尔黑白",
812
+ "Ocean": "海洋",
813
+ "Pastel Dream": "蜡笔梦幻",
814
+ "Sunset": "日落",
815
+ "Warm Beige": "温暖米色",
816
+ }
817
+
818
+ # Add inverse mappings to ensure completeness
819
+ for k, v in CN_TO_EN.items():
820
+ if v not in EN_TO_CN:
821
+ EN_TO_CN[v] = k
822
+
823
+ # Expose helpers
824
+ __all__ = [
825
+ "CITIES",
826
+ "get_countries",
827
+ "get_provinces",
828
+ "get_cities",
829
+ "get_city_full_name",
830
+ "translate",
831
+ "get_country_key",
832
+ "get_manual_coordinates",
833
+ ]
834
+
835
+ # Manual overrides for city centers to ensure logical centering (e.g. Tiananmen for Beijing)
836
+ CITY_CENTERS = {
837
+ "北京": (39.9042, 116.4074),
838
+ "Beijing": (39.9042, 116.4074),
839
+ "上海": (31.2304, 121.4737),
840
+ "Shanghai": (31.2304, 121.4737),
841
+ "广州": (23.1291, 113.2644),
842
+ "Guangzhou": (23.1291, 113.2644),
843
+ "深圳": (22.5422, 114.0579),
844
+ "Shenzhen": (22.5422, 114.0579),
845
+ "杭州": (30.2741, 120.1551),
846
+ "Hangzhou": (30.2741, 120.1551),
847
+ "成都": (30.6570, 104.0660),
848
+ "Chengdu": (30.6570, 104.0660),
849
+ "南京": (32.0603, 118.7969),
850
+ "Nanjing": (32.0603, 118.7969),
851
+ "武汉": (30.5928, 114.3055),
852
+ "Wuhan": (30.5928, 114.3055),
853
+ "西安": (34.3416, 108.9398),
854
+ "Xi'an": (34.3416, 108.9398),
855
+ "苏州": (31.2990, 120.5853),
856
+ "Suzhou": (31.2990, 120.5853),
857
+ "重庆": (29.5630, 106.5516),
858
+ "Chongqing": (29.5630, 106.5516),
859
+ "天津": (39.1255, 117.1901),
860
+ "Tianjin": (39.1255, 117.1901),
861
+ "香港": (22.3193, 114.1694),
862
+ "Hong Kong": (22.3193, 114.1694),
863
+ "台北": (25.0330, 121.5654),
864
+ "Taipei": (25.0330, 121.5654),
865
+ "New York City": (40.7128, -74.0060),
866
+ "纽约": (40.7128, -74.0060),
867
+ "London": (51.5074, -0.1278),
868
+ "伦敦": (51.5074, -0.1278),
869
+ "Paris": (48.8566, 2.3522),
870
+ "巴黎": (48.8566, 2.3522),
871
+ "Tokyo": (35.6895, 139.6917),
872
+ "东京": (35.6895, 139.6917),
873
+ "桂林": (25.2736, 110.2902),
874
+ "Guilin": (25.2736, 110.2902),
875
+ }
876
+
877
+
878
+ def get_manual_coordinates(city_name):
879
+ """Return manual coordinates if available, else None."""
880
+ if not city_name:
881
+ return None
882
+ return CITY_CENTERS.get(city_name)
883
+
884
+
885
+ def translate(text, target_lang="en"):
886
+ """
887
+ Simple translation helper.
888
+ target_lang: 'en' or 'cn'
889
+ """
890
+ if not text:
891
+ return text
892
+
893
+ if target_lang == "en":
894
+ # If text is already in CN_TO_EN keys (Chinese), return the English value
895
+ return CN_TO_EN.get(text, text)
896
+
897
+ if target_lang == "cn":
898
+ # If text is in EN_TO_CN keys (English), return the Chinese value
899
+ return EN_TO_CN.get(text, text)
900
+
901
+ return text
902
+
903
+
904
+ def get_countries(lang="en"):
905
+ """Return list of countries in the target language."""
906
+ countries = []
907
+ for country_key in sorted(CITIES.keys()):
908
+ countries.append(translate(country_key, lang))
909
+ return sorted(list(set(countries)))
910
+
911
+
912
+ def get_country_key(name):
913
+ """Map a localized name back to the CITIES dictionary key."""
914
+ # Check if it's already a key
915
+ if name in CITIES:
916
+ return name
917
+ # Check if it's an English name that needs to be Chinese key
918
+ cn_name = EN_TO_CN.get(name)
919
+ if cn_name in CITIES:
920
+ return cn_name
921
+ # Check if it's a Chinese name that needs to be English key
922
+ en_name = CN_TO_EN.get(name)
923
+ if en_name in CITIES:
924
+ return en_name
925
+ return name
926
+
927
+
928
+ def get_provinces(country_name, lang="en"):
929
+ """Return sorted list of provinces/states for a given country in target language."""
930
+ country_key = get_country_key(country_name)
931
+ if country_key in CITIES:
932
+ # Translate provinces to target lang
933
+ provinces = [translate(p, lang) for p in CITIES[country_key].keys()]
934
+ return sorted(provinces)
935
+ return []
936
+
937
+
938
+ def get_cities(country_name, province_name, lang="en"):
939
+ """Return sorted list of cities in target language."""
940
+ country_key = get_country_key(country_name)
941
+ # To find the province key, we might need to map it back if it was translated
942
+ # For now, let's assume provincial keys in CITIES are what we search against
943
+ # If the province_name is translated, we need to find the original key
944
+ province_key = province_name
945
+ if country_key in CITIES:
946
+ # Check if province_name is a direct key
947
+ if province_name not in CITIES[country_key]:
948
+ # Try to find the key that translates to province_name
949
+ for p_key in CITIES[country_key].keys():
950
+ if translate(p_key, lang) == province_name:
951
+ province_key = p_key
952
+ break
953
+
954
+ if province_key in CITIES[country_key]:
955
+ cities = [translate(c, lang) for c in CITIES[country_key][province_key]]
956
+ return sorted(cities)
957
+ return []
958
+
959
+
960
+ def search_cities(query):
961
+ """
962
+ Search for cities matching the query (checks both Native and English names).
963
+ Returns list of tuples: (city, province, country)
964
+ """
965
+ if not query or len(query) < 2:
966
+ return []
967
+
968
+ query = query.lower()
969
+ results = []
970
+
971
+ for country, provinces in CITIES.items():
972
+ country_en = CN_TO_EN.get(country, "").lower()
973
+ for province, cities in provinces.items():
974
+ for city in cities:
975
+ city_en = CN_TO_EN.get(city, "").lower()
976
+ # Match against native name or English name
977
+ if query in city.lower() or (city_en and query in city_en):
978
+ results.append((city, province, country))
979
+
980
+ # Sort by city name
981
+ return sorted(results, key=lambda x: x[0])[:20]
982
+
983
+
984
+ def get_city_full_name(city, province, country, target_lang=None):
985
+ """
986
+ Return formatted full name.
987
+ If target_lang is provided ('en' or 'cn'), translates components.
988
+ """
989
+ if target_lang:
990
+ city = translate(city, target_lang)
991
+ province = translate(province, target_lang)
992
+ country = translate(country, target_lang)
993
+
994
+ if province == city: # Direct-controlled municipalities
995
+ return f"{city}, {country}"
996
+ return f"{city}, {province}, {country}"
create_map_poster.py ADDED
@@ -0,0 +1,822 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import osmnx as ox
2
+ import pandas as pd
3
+ import matplotlib.pyplot as plt
4
+ from matplotlib.font_manager import FontProperties
5
+ import matplotlib.colors as mcolors
6
+ import numpy as np
7
+ from geopy.geocoders import Nominatim
8
+ from tqdm import tqdm
9
+ import time
10
+ import json
11
+ import os
12
+ from datetime import datetime
13
+ import argparse
14
+
15
+ # Explicitly enable caching
16
+ ox.settings.use_cache = True
17
+
18
+ THEMES_DIR = "themes"
19
+ FONTS_DIR = "fonts"
20
+ POSTERS_DIR = "posters"
21
+
22
+
23
+ def load_fonts():
24
+ """
25
+ Load fonts for both English (Goudy Old Style) and Chinese (HYWenRunSongYunU).
26
+ """
27
+ fonts = {
28
+ "en_bold": os.path.join(FONTS_DIR, "GoudyOldStyle-Bold.ttf"),
29
+ "en_regular": os.path.join(FONTS_DIR, "GoudyOldStyle-Regular.ttf"),
30
+ "cn": os.path.join(FONTS_DIR, "HYWenRunSongYunU.ttf"),
31
+ }
32
+
33
+ # Verify fonts exist
34
+ available_fonts = {}
35
+ for key, path in fonts.items():
36
+ if os.path.exists(path):
37
+ available_fonts[key] = path
38
+ else:
39
+ print(f"⚠ Font not found: {path}")
40
+
41
+ return available_fonts if available_fonts else None
42
+
43
+
44
+ FONTS = load_fonts()
45
+
46
+
47
+ def generate_output_filename(city, theme_name, output_format):
48
+ """
49
+ Generate unique output filename with city, theme, and datetime.
50
+ """
51
+ if not os.path.exists(POSTERS_DIR):
52
+ os.makedirs(POSTERS_DIR)
53
+
54
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
55
+ city_slug = city.lower().replace(" ", "_")
56
+ ext = output_format.lower()
57
+ filename = f"{city_slug}_{theme_name}_{timestamp}.{ext}"
58
+ return os.path.join(POSTERS_DIR, filename)
59
+
60
+
61
+ def get_available_themes():
62
+ """
63
+ Scans the themes directory and returns a list of available theme names.
64
+ """
65
+ if not os.path.exists(THEMES_DIR):
66
+ os.makedirs(THEMES_DIR)
67
+ return []
68
+
69
+ themes = []
70
+ for file in sorted(os.listdir(THEMES_DIR)):
71
+ if file.endswith(".json"):
72
+ theme_name = file[:-5] # Remove .json extension
73
+ themes.append(theme_name)
74
+ return themes
75
+
76
+
77
+ def load_theme(theme_name="feature_based"):
78
+ """
79
+ Load theme from JSON file in themes directory.
80
+ """
81
+ theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json")
82
+
83
+ if not os.path.exists(theme_file):
84
+ print(
85
+ f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme."
86
+ )
87
+ # Fallback to embedded default theme
88
+ return {
89
+ "name": "Feature-Based Shading",
90
+ "bg": "#FFFFFF",
91
+ "text": "#000000",
92
+ "gradient_color": "#FFFFFF",
93
+ "water": "#C0C0C0",
94
+ "parks": "#F0F0F0",
95
+ "road_motorway": "#0A0A0A",
96
+ "road_primary": "#1A1A1A",
97
+ "road_secondary": "#2A2A2A",
98
+ "road_tertiary": "#3A3A3A",
99
+ "road_residential": "#4A4A4A",
100
+ "road_default": "#3A3A3A",
101
+ }
102
+
103
+ with open(theme_file, "r") as f:
104
+ theme = json.load(f)
105
+ print(f"✓ Loaded theme: {theme.get('name', theme_name)}")
106
+ if "description" in theme:
107
+ print(f" {theme['description']}")
108
+ return theme
109
+
110
+
111
+ # Load theme (can be changed via command line or input)
112
+ THEME = None # Will be loaded later
113
+
114
+
115
+ def create_gradient_fade(ax, color, location="bottom", zorder=10):
116
+ """
117
+ Creates a fade effect at the top or bottom of the map.
118
+ """
119
+ vals = np.linspace(0, 1, 256).reshape(-1, 1)
120
+ gradient = np.hstack((vals, vals))
121
+
122
+ rgb = mcolors.to_rgb(color)
123
+ my_colors = np.zeros((256, 4))
124
+ my_colors[:, 0] = rgb[0]
125
+ my_colors[:, 1] = rgb[1]
126
+ my_colors[:, 2] = rgb[2]
127
+
128
+ if location == "bottom":
129
+ my_colors[:, 3] = np.linspace(1, 0, 256)
130
+ extent_y_start = 0
131
+ extent_y_end = 0.25
132
+ else:
133
+ my_colors[:, 3] = np.linspace(0, 1, 256)
134
+ extent_y_start = 0.75
135
+ extent_y_end = 1.0
136
+
137
+ custom_cmap = mcolors.ListedColormap(my_colors)
138
+
139
+ xlim = ax.get_xlim()
140
+ ylim = ax.get_ylim()
141
+ y_range = ylim[1] - ylim[0]
142
+
143
+ y_bottom = ylim[0] + y_range * extent_y_start
144
+ y_top = ylim[0] + y_range * extent_y_end
145
+
146
+ ax.imshow(
147
+ gradient,
148
+ extent=[xlim[0], xlim[1], y_bottom, y_top],
149
+ aspect="auto",
150
+ cmap=custom_cmap,
151
+ zorder=zorder,
152
+ origin="lower",
153
+ )
154
+
155
+
156
+ def get_edge_colors_by_type(
157
+ G, show_motorway=True, show_primary=True, show_secondary=True
158
+ ):
159
+ """
160
+ Assigns colors to edges based on road type hierarchy.
161
+ Returns a list of colors corresponding to each edge in the graph.
162
+ If a layer is hidden, returns 'none' for that edge.
163
+ """
164
+ edge_colors = []
165
+
166
+ for u, v, data in G.edges(data=True):
167
+ # Get the highway type (can be a list or string)
168
+ highway = data.get("highway", "unclassified")
169
+
170
+ # Handle list of highway types (take the first one)
171
+ if isinstance(highway, list):
172
+ highway = highway[0] if highway else "unclassified"
173
+
174
+ # Assign color based on road type
175
+ if highway in ["motorway", "motorway_link"]:
176
+ color = THEME["road_motorway"] if show_motorway else "none"
177
+ elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
178
+ color = THEME["road_primary"] if show_primary else "none"
179
+ elif highway in ["secondary", "secondary_link"]:
180
+ color = THEME["road_secondary"] if show_secondary else "none"
181
+ elif highway in ["tertiary", "tertiary_link"]:
182
+ color = (
183
+ THEME["road_tertiary"] if show_secondary else "none"
184
+ ) # Group tertiary with secondary
185
+ elif highway in [
186
+ "residential",
187
+ "living_street",
188
+ "unclassified",
189
+ "service",
190
+ "road",
191
+ ]:
192
+ color = THEME["road_residential"]
193
+ elif highway in ["path", "footway", "track", "cycleway", "pedestrian"]:
194
+ # Use residential color or a lighter version if defined, for now residential
195
+ color = THEME["road_residential"]
196
+ else:
197
+ color = THEME["road_default"]
198
+
199
+ edge_colors.append(color)
200
+
201
+ return edge_colors
202
+
203
+
204
+ def get_edge_widths_by_type(G):
205
+ """
206
+ Assigns line widths to edges based on road type.
207
+ Major roads get thicker lines.
208
+ """
209
+ edge_widths = []
210
+
211
+ for u, v, data in G.edges(data=True):
212
+ highway = data.get("highway", "unclassified")
213
+
214
+ if isinstance(highway, list):
215
+ highway = highway[0] if highway else "unclassified"
216
+
217
+ # Assign width based on road importance (increased for visibility)
218
+ if highway in ["motorway", "motorway_link"]:
219
+ width = 1.6
220
+ elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
221
+ width = 1.3
222
+ elif highway in ["secondary", "secondary_link"]:
223
+ width = 1.0
224
+ elif highway in ["tertiary", "tertiary_link"]:
225
+ width = 0.8
226
+ elif highway in [
227
+ "residential",
228
+ "living_street",
229
+ "unclassified",
230
+ "service",
231
+ "road",
232
+ ]:
233
+ width = 0.8
234
+ elif highway in ["path", "footway", "track", "cycleway", "pedestrian"]:
235
+ width = 0.5
236
+ else:
237
+ width = 0.6
238
+
239
+ edge_widths.append(width)
240
+
241
+ return edge_widths
242
+
243
+
244
+ def has_chinese(text):
245
+ """Check if a string contains any Chinese characters."""
246
+ if not text:
247
+ return False
248
+ for char in text:
249
+ if "\u4e00" <= char <= "\u9fff":
250
+ return True
251
+ return False
252
+
253
+
254
+ def get_coordinates(city, country):
255
+ """
256
+ Fetches coordinates for a given city and country using OSMnx (which handles caching and retry).
257
+ First checks for manual overrides in cities_data.
258
+ """
259
+ from cities_data import get_manual_coordinates
260
+
261
+ # Check for manual override first (e.g. for large cities like Beijing)
262
+ manual_coords = get_manual_coordinates(city)
263
+ if manual_coords:
264
+ print(f"✓ Using manual coordinate override for {city}: {manual_coords}")
265
+ return manual_coords
266
+
267
+ print(f"Looking up coordinates for {city}, {country} via OSMnx...")
268
+
269
+ # Configure OSMnx settings for robustness
270
+ ox.settings.timeout = 30
271
+ ox.settings.user_agent = "modelscope_map_to_poster/1.0"
272
+
273
+ try:
274
+ # Try City + Country
275
+ query = f"{city}, {country}"
276
+ lat, lon = ox.geocode(query)
277
+ print(f"✓ Found: {lat}, {lon}")
278
+ return (lat, lon)
279
+ except Exception as e:
280
+ print(f"⚠ First attempt failed: {e}")
281
+ try:
282
+ # Try just City
283
+ print(f"Retrying with city name only: {city}")
284
+ lat, lon = ox.geocode(city)
285
+ print(f"✓ Found: {lat}, {lon}")
286
+ return (lat, lon)
287
+ except Exception as e2:
288
+ print(f"⚠ Second attempt failed: {e2}")
289
+ raise ValueError(f"Could not find coordinates for {city}, {country}")
290
+
291
+
292
+ def create_poster(
293
+ city,
294
+ country,
295
+ point,
296
+ dist,
297
+ output_file,
298
+ output_format,
299
+ width=12,
300
+ height=16,
301
+ no_crop=False,
302
+ show_motorway=True,
303
+ show_primary=True,
304
+ show_secondary=True,
305
+ show_water=True,
306
+ show_parks=True,
307
+ ):
308
+ msg = f"Generating map for {city}, {country}..."
309
+ print(f"\n{msg}")
310
+ yield msg
311
+
312
+ # Calculate non-square distances to match figure aspect ratio
313
+ # dist is the vertical (North-South) half-distance
314
+ dist_ns = dist
315
+ dist_ew = dist * (width / height)
316
+
317
+ # Progress bar for data fetching
318
+ # Note: tqdm writes to stderr, we will just yield status updates for the UI
319
+
320
+ # 1. Fetch Street Network using a bounding box
321
+ yield "Downloading street network..."
322
+
323
+ import math
324
+
325
+ lat, lon = point
326
+ delta_lat = dist_ns / 111320.0
327
+ delta_lon = dist_ew / (111320.0 * math.cos(math.radians(lat)))
328
+
329
+ north, south = lat + delta_lat, lat - delta_lat
330
+ west, east = lon - delta_lon, lon + delta_lon
331
+
332
+ bbox = (west, south, east, north)
333
+ G = ox.graph_from_bbox(bbox, network_type="all")
334
+
335
+ # 2. Fetch Water and Parks in one request
336
+ yield "Downloading features (water, parks)..."
337
+ tags = {
338
+ "natural": ["water", "wood", "scrub"],
339
+ "waterway": ["riverbank", "dock"],
340
+ "leisure": ["park", "garden", "nature_reserve"],
341
+ "landuse": [
342
+ "forest",
343
+ "grass",
344
+ "cemetery",
345
+ "recreation_ground",
346
+ "village_green",
347
+ ],
348
+ }
349
+
350
+ water_polys = None
351
+ water_lines = None
352
+ parks = None
353
+
354
+ try:
355
+ features = ox.features_from_bbox(bbox, tags=tags)
356
+
357
+ # Separate features and simplify geometries for faster rendering
358
+ if features is not None and not features.empty:
359
+ # Safe mask creation for Water
360
+ water_mask = pd.Series(False, index=features.index)
361
+ if "natural" in features.columns:
362
+ water_mask |= features["natural"].isin(["water"])
363
+ if "waterway" in features.columns:
364
+ water_mask |= features["waterway"].notna()
365
+
366
+ water = features[water_mask]
367
+
368
+ if not water.empty:
369
+ # Water Polygons (Lakes, Wide Rivers)
370
+ water_polys = water[
371
+ water.geometry.type.isin(["Polygon", "MultiPolygon"])
372
+ ]
373
+ if not water_polys.empty:
374
+ water_polys.geometry = water_polys.geometry.simplify(
375
+ tolerance=0.00001, preserve_topology=True
376
+ )
377
+
378
+ # Water Lines (Rivers, Streams represented as lines)
379
+ water_lines = water[
380
+ water.geometry.type.isin(["LineString", "MultiLineString"])
381
+ ]
382
+ else:
383
+ water_polys, water_lines = None, None # No water features found
384
+
385
+ # Green space/Parks features
386
+ park_mask = pd.Series(False, index=features.index)
387
+ if "leisure" in features.columns:
388
+ park_mask |= features["leisure"].notna()
389
+ if "landuse" in features.columns:
390
+ park_mask |= features["landuse"].isin(
391
+ [
392
+ "forest",
393
+ "grass",
394
+ "cemetery",
395
+ "recreation_ground",
396
+ "village_green",
397
+ ]
398
+ )
399
+ if "natural" in features.columns:
400
+ park_mask |= features["natural"].isin(["wood", "scrub"])
401
+
402
+ parks = features[park_mask]
403
+ if not parks.empty:
404
+ parks = parks[parks.geometry.type.isin(["Polygon", "MultiPolygon"])]
405
+ # Simplify geometries
406
+ parks.geometry = parks.geometry.simplify(
407
+ tolerance=0.00001, preserve_topology=True
408
+ )
409
+ else:
410
+ parks = None # No park features found
411
+ else:
412
+ water_polys, water_lines, parks = None, None, None
413
+ except Exception as e:
414
+ print(f"Warning: Could not fetch features: {e}")
415
+ water_polys, water_lines, parks = None, None, None
416
+
417
+ print("✓ All data downloaded successfully!")
418
+ yield "Data downloaded. Rendering map..."
419
+
420
+ # 2. Setup Plot
421
+ print("Rendering map...")
422
+ fig, ax = plt.subplots(figsize=(width, height), facecolor=THEME["bg"])
423
+ ax.set_facecolor(THEME["bg"])
424
+ ax.set_position([0, 0, 1, 1])
425
+
426
+ # 3. Plot Layers
427
+ # Layer 1: Polygons (filter to only plot polygon/multipolygon geometries, not points)
428
+ # Layer 1: Water (Polygons and Lines)
429
+ if show_water:
430
+ if water_polys is not None and not water_polys.empty:
431
+ water_polys.plot(
432
+ ax=ax, facecolor=THEME["water"], edgecolor="none", zorder=1
433
+ )
434
+
435
+ if water_lines is not None and not water_lines.empty:
436
+ # Plot water lines (rivers) with some thickness
437
+ water_lines.plot(ax=ax, color=THEME["water"], linewidth=2.0, zorder=1)
438
+
439
+ if show_parks and parks is not None and not parks.empty:
440
+ # Filter to only polygon/multipolygon geometries to avoid point features showing as dots
441
+ parks_polys = parks[parks.geometry.type.isin(["Polygon", "MultiPolygon"])]
442
+ if not parks_polys.empty:
443
+ parks_polys.plot(
444
+ ax=ax, facecolor=THEME["parks"], edgecolor="none", zorder=2
445
+ )
446
+
447
+ # Layer 2: Roads with hierarchy coloring
448
+ print("Applying road hierarchy colors...")
449
+ yield "Applying road styles..."
450
+ edge_colors = get_edge_colors_by_type(
451
+ G,
452
+ show_motorway=show_motorway,
453
+ show_primary=show_primary,
454
+ show_secondary=show_secondary,
455
+ )
456
+ edge_widths = get_edge_widths_by_type(G)
457
+
458
+ ox.plot_graph(
459
+ G,
460
+ ax=ax,
461
+ bgcolor=THEME["bg"],
462
+ node_size=0,
463
+ edge_color=edge_colors,
464
+ edge_linewidth=edge_widths,
465
+ show=False,
466
+ close=False,
467
+ )
468
+
469
+ # Layer 3: Gradients (Top and Bottom)
470
+ create_gradient_fade(ax, THEME["gradient_color"], location="bottom", zorder=10)
471
+ create_gradient_fade(ax, THEME["gradient_color"], location="top", zorder=10)
472
+
473
+ # 4. Typography
474
+ is_chinese = has_chinese(city)
475
+
476
+ if FONTS:
477
+ if is_chinese:
478
+ # Chinese styling
479
+ font_path = FONTS.get("cn") or FONTS.get("en_bold")
480
+ font_main_base = FontProperties(fname=font_path)
481
+ font_sub_base = FontProperties(fname=font_path)
482
+ font_coords = FontProperties(fname=font_path, size=14)
483
+
484
+ display_city = city
485
+ display_country = country
486
+ else:
487
+ # English styling (Original)
488
+ font_path_bold = FONTS.get("en_bold")
489
+ font_path_reg = FONTS.get("en_regular")
490
+ font_main_base = FontProperties(fname=font_path_bold)
491
+ font_sub_base = FontProperties(fname=font_path_reg)
492
+ font_coords = FontProperties(fname=font_path_reg, size=14)
493
+
494
+ # Spaced and uppercase for English
495
+ display_city = " ".join(list(city.upper()))
496
+ display_country = country.upper()
497
+ else:
498
+ # Fallback to system fonts
499
+ font_main_base = FontProperties(family="serif", weight="bold")
500
+ font_sub_base = FontProperties(family="serif")
501
+ font_coords = FontProperties(family="monospace", size=14)
502
+
503
+ if is_chinese:
504
+ display_city = city
505
+ display_country = country
506
+ else:
507
+ display_city = " ".join(list(city.upper()))
508
+ display_country = country.upper()
509
+
510
+ # Dynamically adjust font size
511
+ base_font_size = 60 if not is_chinese else 54
512
+ city_char_count = len(city)
513
+ if not is_chinese and city_char_count > 10:
514
+ scale_factor = 10 / city_char_count
515
+ adjusted_font_size = max(base_font_size * scale_factor, 24)
516
+ elif is_chinese and city_char_count > 6:
517
+ scale_factor = 6 / city_char_count
518
+ adjusted_font_size = max(base_font_size * scale_factor, 32)
519
+ else:
520
+ adjusted_font_size = base_font_size
521
+
522
+ font_main = font_main_base.copy()
523
+ font_main.set_size(adjusted_font_size)
524
+
525
+ font_sub = font_sub_base.copy()
526
+ font_sub.set_size(22)
527
+
528
+ # --- BOTTOM TEXT ---
529
+ ax.text(
530
+ 0.5,
531
+ 0.14,
532
+ display_city,
533
+ transform=ax.transAxes,
534
+ color=THEME["text"],
535
+ ha="center",
536
+ fontproperties=font_main,
537
+ zorder=11,
538
+ )
539
+
540
+ ax.text(
541
+ 0.5,
542
+ 0.10,
543
+ display_country,
544
+ transform=ax.transAxes,
545
+ color=THEME["text"],
546
+ ha="center",
547
+ fontproperties=font_sub,
548
+ zorder=11,
549
+ )
550
+
551
+ lat, lon = point
552
+ coords_text = (
553
+ f"{lat:.4f}° N / {lon:.4f}° E"
554
+ if lat >= 0
555
+ else f"{abs(lat):.4f}° S / {lon:.4f}° E"
556
+ )
557
+ if lon < 0:
558
+ coords_text = coords_text.replace("E", "W")
559
+
560
+ ax.text(
561
+ 0.5,
562
+ 0.07,
563
+ coords_text,
564
+ transform=ax.transAxes,
565
+ color=THEME["text"],
566
+ alpha=0.7,
567
+ ha="center",
568
+ fontproperties=font_coords,
569
+ zorder=11,
570
+ )
571
+
572
+ ax.plot(
573
+ [0.4, 0.6],
574
+ [0.125, 0.125],
575
+ transform=ax.transAxes,
576
+ color=THEME["text"],
577
+ linewidth=1,
578
+ zorder=11,
579
+ )
580
+
581
+ # --- ATTRIBUTION (bottom right) ---
582
+ attr_font = font_sub_base.copy()
583
+ attr_font.set_size(8)
584
+
585
+ ax.text(
586
+ 0.98,
587
+ 0.02,
588
+ "© OpenStreetMap contributors",
589
+ transform=ax.transAxes,
590
+ color=THEME["text"],
591
+ alpha=0.5,
592
+ ha="right",
593
+ va="bottom",
594
+ fontproperties=attr_font,
595
+ zorder=11,
596
+ )
597
+
598
+ # 5. Save
599
+ print(f"Saving to {output_file}...")
600
+ yield f"Saving to {output_file}..."
601
+
602
+ fmt = output_format.lower()
603
+ save_kwargs = dict(facecolor=THEME["bg"], pad_inches=0.05)
604
+
605
+ if not no_crop:
606
+ save_kwargs["bbox_inches"] = "tight"
607
+
608
+ # DPI matters mainly for raster formats
609
+ if fmt == "png":
610
+ save_kwargs["dpi"] = 300
611
+
612
+ plt.savefig(output_file, format=fmt, **save_kwargs)
613
+
614
+ plt.close()
615
+ print(f"✓ Done! Poster saved as {output_file}")
616
+ yield "Done!"
617
+
618
+
619
+ def print_examples():
620
+ """Print usage examples."""
621
+ print("""
622
+ City Map Poster Generator
623
+ =========================
624
+
625
+ Usage:
626
+ python create_map_poster.py --city <city> --country <country> [options]
627
+
628
+ Examples:
629
+ # Iconic grid patterns
630
+ python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid
631
+ python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid
632
+
633
+ # Waterfront & canals
634
+ python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network
635
+ python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals
636
+ python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline
637
+
638
+ # Radial patterns
639
+ python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards
640
+ python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads
641
+
642
+ # Organic old cities
643
+ python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets
644
+ python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze
645
+ python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient street layout
646
+
647
+ # Coastal cities
648
+ python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid
649
+ python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city
650
+ python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula
651
+
652
+ # River cities
653
+ python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves
654
+ python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split
655
+
656
+ # List themes
657
+ python create_map_poster.py --list-themes
658
+
659
+ Options:
660
+ --city, -c City name (required)
661
+ --country, -C Country name (required)
662
+ --theme, -t Theme name (default: feature_based)
663
+ --distance, -d Map radius in meters (default: 29000)
664
+ --list-themes List all available themes
665
+
666
+ Distance guide:
667
+ 4000-6000m Small/dense cities (Venice, Amsterdam old center)
668
+ 8000-12000m Medium cities, focused downtown (Paris, Barcelona)
669
+ 15000-20000m Large metros, full city view (Tokyo, Mumbai)
670
+
671
+ Available themes can be found in the 'themes/' directory.
672
+ Generated posters are saved to 'posters/' directory.
673
+ """)
674
+
675
+
676
+ def list_themes():
677
+ """List all available themes with descriptions."""
678
+ available_themes = get_available_themes()
679
+ if not available_themes:
680
+ print("No themes found in 'themes/' directory.")
681
+ return
682
+
683
+ print("\nAvailable Themes:")
684
+ print("-" * 60)
685
+ for theme_name in available_themes:
686
+ theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json")
687
+ try:
688
+ with open(theme_path, "r") as f:
689
+ theme_data = json.load(f)
690
+ display_name = theme_data.get("name", theme_name)
691
+ description = theme_data.get("description", "")
692
+ except:
693
+ display_name = theme_name
694
+ description = ""
695
+ print(f" {theme_name}")
696
+ print(f" {display_name}")
697
+ if description:
698
+ print(f" {description}")
699
+ print()
700
+
701
+
702
+ if __name__ == "__main__":
703
+ parser = argparse.ArgumentParser(
704
+ description="Generate beautiful map posters for any city",
705
+ formatter_class=argparse.RawDescriptionHelpFormatter,
706
+ epilog="""
707
+ Examples:
708
+ python create_map_poster.py --city "New York" --country "USA"
709
+ python create_map_poster.py --city Tokyo --country Japan --theme midnight_blue
710
+ python create_map_poster.py --city Paris --country France --theme noir --distance 15000
711
+ python create_map_poster.py --list-themes
712
+ """,
713
+ )
714
+
715
+ parser.add_argument("--city", "-c", type=str, help="City name")
716
+ parser.add_argument("--country", "-C", type=str, help="Country name")
717
+ parser.add_argument(
718
+ "--theme",
719
+ "-t",
720
+ type=str,
721
+ default="feature_based",
722
+ help="Theme name (default: feature_based)",
723
+ )
724
+ parser.add_argument(
725
+ "--distance",
726
+ "-d",
727
+ type=int,
728
+ default=10000,
729
+ help="Map radius in meters (default: 10000)",
730
+ )
731
+ parser.add_argument(
732
+ "--width",
733
+ "-W",
734
+ type=float,
735
+ default=12.0,
736
+ help="Poster width in inches (default: 12.0)",
737
+ )
738
+ parser.add_argument(
739
+ "--height",
740
+ "-H",
741
+ type=float,
742
+ default=16.0,
743
+ help="Poster height in inches (default: 16.0)",
744
+ )
745
+ parser.add_argument(
746
+ "--no-crop",
747
+ action="store_true",
748
+ help="Do not crop the image to the data extent (keeps background)",
749
+ )
750
+ parser.add_argument(
751
+ "--list-themes", action="store_true", help="List all available themes"
752
+ )
753
+ parser.add_argument(
754
+ "--format",
755
+ "-f",
756
+ default="png",
757
+ choices=["png", "svg", "pdf"],
758
+ help="Output format for the poster (default: png)",
759
+ )
760
+
761
+ args = parser.parse_args()
762
+
763
+ # If no arguments provided, show examples
764
+ if len(os.sys.argv) == 1:
765
+ print_examples()
766
+ os.sys.exit(0)
767
+
768
+ # List themes if requested
769
+ if args.list_themes:
770
+ list_themes()
771
+ os.sys.exit(0)
772
+
773
+ # Validate required arguments
774
+ if not args.city or not args.country:
775
+ print("Error: --city and --country are required.\n")
776
+ print_examples()
777
+ os.sys.exit(1)
778
+
779
+ # Validate theme exists
780
+ available_themes = get_available_themes()
781
+ if args.theme not in available_themes:
782
+ print(f"Error: Theme '{args.theme}' not found.")
783
+ print(f"Available themes: {', '.join(available_themes)}")
784
+ os.sys.exit(1)
785
+
786
+ print("=" * 50)
787
+ print("City Map Poster Generator")
788
+ print("=" * 50)
789
+
790
+ # Load theme
791
+ THEME = load_theme(args.theme)
792
+
793
+ # Get coordinates and generate poster
794
+ try:
795
+ coords = get_coordinates(args.city, args.country)
796
+ output_file = generate_output_filename(args.city, args.theme, args.format)
797
+
798
+ # Iterate over the generator to execute it
799
+ for status in create_poster(
800
+ args.city,
801
+ args.country,
802
+ coords,
803
+ args.distance,
804
+ output_file,
805
+ args.format,
806
+ width=args.width,
807
+ height=args.height,
808
+ no_crop=args.no_crop,
809
+ ):
810
+ # We already print inside the function, but we act as a consumer here
811
+ pass
812
+
813
+ print("\n" + "=" * 50)
814
+ print("✓ Poster generation complete!")
815
+ print("=" * 50)
816
+
817
+ except Exception as e:
818
+ print(f"\n✗ Error: {e}")
819
+ import traceback
820
+
821
+ traceback.print_exc()
822
+ os.sys.exit(1)
fonts/GoudyOldStyle-Bold.ttf ADDED
Binary file (82.8 kB). View file
 
fonts/GoudyOldStyle-Regular.ttf ADDED
Binary file (81.4 kB). View file
 
fonts/HYWenRunSongYunU.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c0d3409418059e092ddb5e8d71139c76025fd3017d07d4a7440474e9e8477437
3
+ size 49883792
pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "maptoposter"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "IsaacHuo", email = "“2210286979@qq.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "certifi==2026.1.4",
12
+ "charset-normalizer==3.4.4",
13
+ "contourpy==1.3.3",
14
+ "cycler==0.12.1",
15
+ "fonttools==4.61.1",
16
+ "geographiclib==2.1",
17
+ "geopandas==1.1.2",
18
+ "geopy==2.4.1",
19
+ "idna==3.11",
20
+ "kiwisolver==1.4.9",
21
+ "matplotlib==3.10.8",
22
+ "networkx==3.6.1",
23
+ "numpy==2.4.0",
24
+ "osmnx==2.0.7",
25
+ "packaging==25.0",
26
+ "pandas==2.3.3",
27
+ "pillow==12.1.0",
28
+ "pyogrio==0.12.1",
29
+ "pyparsing==3.3.1",
30
+ "pyproj==3.7.2",
31
+ "python-dateutil==2.9.0.post0",
32
+ "pytz==2025.2",
33
+ "requests==2.32.5",
34
+ "scipy==1.16.3",
35
+ "shapely==2.1.2",
36
+ "six==1.17.0",
37
+ "tqdm==4.67.1",
38
+ "tzdata==2025.3",
39
+ "urllib3==2.6.3",
40
+ "gradio>=4.0.0",
41
+ ]
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.9.24,<0.10.0"]
45
+ build-backend = "uv_build"
requirements.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ certifi==2026.1.4
2
+ charset-normalizer==3.4.4
3
+ contourpy==1.3.3
4
+ cycler==0.12.1
5
+ fonttools==4.61.1
6
+ geographiclib==2.1
7
+ geopandas==1.1.2
8
+ geopy==2.4.1
9
+ idna==3.11
10
+ kiwisolver==1.4.9
11
+ matplotlib==3.10.8
12
+ networkx==3.6.1
13
+ numpy==2.4.0
14
+ osmnx==2.0.7
15
+ packaging==25.0
16
+ pandas==2.3.3
17
+ pillow==12.1.0
18
+ pyogrio==0.12.1
19
+ pyparsing==3.3.1
20
+ pyproj==3.7.2
21
+ python-dateutil==2.9.0.post0
22
+ pytz==2025.2
23
+ requests==2.32.5
24
+ scipy==1.16.3
25
+ shapely==2.1.2
26
+ six==1.17.0
27
+ tqdm==4.67.1
28
+ tzdata==2025.3
29
+ urllib3==2.6.3
restart.sh ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Port to check
4
+ PORT=7860
5
+
6
+ echo "Checking for processes on port $PORT..."
7
+
8
+ # Find and kill processes using the port
9
+ PIDS=$(lsof -t -i:$PORT)
10
+
11
+ if [ -n "$PIDS" ]; then
12
+ echo "Killing processes on port $PORT: $PIDS"
13
+ kill -9 $PIDS
14
+ sleep 1
15
+ else
16
+ echo "No processes found on port $PORT."
17
+ fi
18
+
19
+ # Path to python in venv
20
+ VENV_PYTHON="./.venv/bin/python"
21
+
22
+ if [ -f "$VENV_PYTHON" ]; then
23
+ echo "Starting application with virtual environment..."
24
+ $VENV_PYTHON app.py
25
+ else
26
+ echo "Virtual environment not found, trying system python..."
27
+ python3 app.py
28
+ fi
src/maptoposter/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ def hello() -> str:
2
+ return "Hello from maptoposter!"
src/maptoposter/py.typed ADDED
File without changes
themes/autumn.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Autumn",
3
+ "description": "Burnt oranges, deep reds, golden yellows - seasonal warmth",
4
+ "bg": "#FBF7F0",
5
+ "text": "#8B4513",
6
+ "gradient_color": "#FBF7F0",
7
+ "water": "#D8CFC0",
8
+ "parks": "#E8E0D0",
9
+ "road_motorway": "#701A00",
10
+ "road_primary": "#8B2500",
11
+ "road_secondary": "#B8450A",
12
+ "road_tertiary": "#CC7A30",
13
+ "road_residential": "#C9A050",
14
+ "road_default": "#B8450A"
15
+ }
themes/blueprint.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Blueprint",
3
+ "description": "Classic architectural blueprint - technical drawing aesthetic",
4
+ "bg": "#1A3A5C",
5
+ "text": "#E8F4FF",
6
+ "gradient_color": "#1A3A5C",
7
+ "water": "#0F2840",
8
+ "parks": "#1E4570",
9
+ "road_motorway": "#E8F4FF",
10
+ "road_primary": "#C5DCF0",
11
+ "road_secondary": "#9FC5E8",
12
+ "road_tertiary": "#7BAED4",
13
+ "road_residential": "#5A96C0",
14
+ "road_default": "#7BAED4"
15
+ }
themes/contrast_zones.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Contrast Zones",
3
+ "description": "Strong contrast showing urban density - darker in center, lighter at edges",
4
+ "bg": "#FFFFFF",
5
+ "text": "#000000",
6
+ "gradient_color": "#FFFFFF",
7
+ "water": "#B0B0B0",
8
+ "parks": "#ECECEC",
9
+ "road_motorway": "#000000",
10
+ "road_primary": "#0F0F0F",
11
+ "road_secondary": "#252525",
12
+ "road_tertiary": "#404040",
13
+ "road_residential": "#5A5A5A",
14
+ "road_default": "#404040"
15
+ }
themes/copper_patina.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Copper Patina",
3
+ "description": "Oxidized copper aesthetic - teal-green patina with copper accents",
4
+ "bg": "#E8F0F0",
5
+ "text": "#2A5A5A",
6
+ "gradient_color": "#E8F0F0",
7
+ "water": "#C0D8D8",
8
+ "parks": "#D8E8E0",
9
+ "road_motorway": "#B87333",
10
+ "road_primary": "#5A8A8A",
11
+ "road_secondary": "#6B9E9E",
12
+ "road_tertiary": "#88B4B4",
13
+ "road_residential": "#A8CCCC",
14
+ "road_default": "#88B4B4"
15
+ }
themes/feature_based.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Feature-Based Shading",
3
+ "description": "Different shades for different road types and features with clear hierarchy",
4
+ "bg": "#FFFFFF",
5
+ "text": "#000000",
6
+ "gradient_color": "#FFFFFF",
7
+ "water": "#C0C0C0",
8
+ "parks": "#F0F0F0",
9
+ "road_motorway": "#0A0A0A",
10
+ "road_primary": "#1A1A1A",
11
+ "road_secondary": "#2A2A2A",
12
+ "road_tertiary": "#3A3A3A",
13
+ "road_residential": "#4A4A4A",
14
+ "road_default": "#3A3A3A"
15
+ }
themes/forest.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Forest",
3
+ "description": "Deep greens and sage tones - organic botanical aesthetic",
4
+ "bg": "#F0F4F0",
5
+ "text": "#2D4A3E",
6
+ "gradient_color": "#F0F4F0",
7
+ "water": "#B8D4D4",
8
+ "parks": "#D4E8D4",
9
+ "road_motorway": "#2D4A3E",
10
+ "road_primary": "#3D6B55",
11
+ "road_secondary": "#5A8A70",
12
+ "road_tertiary": "#7AAA90",
13
+ "road_residential": "#A0C8B0",
14
+ "road_default": "#7AAA90"
15
+ }
themes/gradient_roads.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Gradient Roads",
3
+ "description": "Smooth gradient from dark center to light edges with subtle features",
4
+ "bg": "#FFFFFF",
5
+ "text": "#000000",
6
+ "gradient_color": "#FFFFFF",
7
+ "water": "#D5D5D5",
8
+ "parks": "#EFEFEF",
9
+ "road_motorway": "#050505",
10
+ "road_primary": "#151515",
11
+ "road_secondary": "#2A2A2A",
12
+ "road_tertiary": "#404040",
13
+ "road_residential": "#555555",
14
+ "road_default": "#404040"
15
+ }
themes/japanese_ink.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Japanese Ink",
3
+ "description": "Traditional ink wash inspired - minimalist with subtle red accent",
4
+ "bg": "#FAF8F5",
5
+ "text": "#2C2C2C",
6
+ "gradient_color": "#FAF8F5",
7
+ "water": "#E8E4E0",
8
+ "parks": "#F0EDE8",
9
+ "road_motorway": "#701A00",
10
+ "road_primary": "#1A1A1A",
11
+ "road_secondary": "#333333",
12
+ "road_tertiary": "#4D4D4D",
13
+ "road_residential": "#666666",
14
+ "road_default": "#4D4D4D"
15
+ }
themes/midnight_blue.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Midnight Blue",
3
+ "description": "Deep navy background with gold/copper roads - luxury atlas aesthetic",
4
+ "bg": "#0A1628",
5
+ "text": "#D4AF37",
6
+ "gradient_color": "#0A1628",
7
+ "water": "#061020",
8
+ "parks": "#0F2235",
9
+ "road_motorway": "#D4AF37",
10
+ "road_primary": "#C9A227",
11
+ "road_secondary": "#A8893A",
12
+ "road_tertiary": "#8B7355",
13
+ "road_residential": "#6B5B4F",
14
+ "road_default": "#8B7355"
15
+ }
themes/monochrome_blue.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Monochrome Blue",
3
+ "description": "Single blue color family with varying saturation - clean and cohesive",
4
+ "bg": "#F5F8FA",
5
+ "text": "#1A3A5C",
6
+ "gradient_color": "#F5F8FA",
7
+ "water": "#D0E0F0",
8
+ "parks": "#E0EAF2",
9
+ "road_motorway": "#1A3A5C",
10
+ "road_primary": "#2A5580",
11
+ "road_secondary": "#4A7AA8",
12
+ "road_tertiary": "#7AA0C8",
13
+ "road_residential": "#A8C4E0",
14
+ "road_default": "#4A7AA8"
15
+ }
themes/neon_cyberpunk.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Neon Cyberpunk",
3
+ "description": "Dark background with electric pink/cyan - bold night city vibes",
4
+ "bg": "#0D0D1A",
5
+ "text": "#00FFFF",
6
+ "gradient_color": "#0D0D1A",
7
+ "water": "#0A0A15",
8
+ "parks": "#151525",
9
+ "road_motorway": "#FF00FF",
10
+ "road_primary": "#00FFFF",
11
+ "road_secondary": "#00C8C8",
12
+ "road_tertiary": "#0098A0",
13
+ "road_residential": "#006870",
14
+ "road_default": "#0098A0"
15
+ }
themes/noir.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Noir",
3
+ "description": "Pure black background with white/gray roads - modern gallery aesthetic",
4
+ "bg": "#000000",
5
+ "text": "#FFFFFF",
6
+ "gradient_color": "#000000",
7
+ "water": "#0A0A0A",
8
+ "parks": "#111111",
9
+ "road_motorway": "#FFFFFF",
10
+ "road_primary": "#E0E0E0",
11
+ "road_secondary": "#B0B0B0",
12
+ "road_tertiary": "#808080",
13
+ "road_residential": "#505050",
14
+ "road_default": "#808080"
15
+ }
themes/ocean.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Ocean",
3
+ "description": "Various blues and teals - perfect for coastal cities",
4
+ "bg": "#F0F8FA",
5
+ "text": "#1A5F7A",
6
+ "gradient_color": "#F0F8FA",
7
+ "water": "#B8D8E8",
8
+ "parks": "#D8EAE8",
9
+ "road_motorway": "#1A5F7A",
10
+ "road_primary": "#2A7A9A",
11
+ "road_secondary": "#4A9AB8",
12
+ "road_tertiary": "#70B8D0",
13
+ "road_residential": "#A0D0E0",
14
+ "road_default": "#4A9AB8"
15
+ }
themes/pastel_dream.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Pastel Dream",
3
+ "description": "Soft muted pastels with dusty blues and mauves - dreamy artistic aesthetic",
4
+ "bg": "#FAF7F2",
5
+ "text": "#5D5A6D",
6
+ "gradient_color": "#FAF7F2",
7
+ "water": "#D4E4ED",
8
+ "parks": "#E8EDE4",
9
+ "road_motorway": "#7B8794",
10
+ "road_primary": "#9BA4B0",
11
+ "road_secondary": "#B5AEBB",
12
+ "road_tertiary": "#C9C0C9",
13
+ "road_residential": "#D8D2D8",
14
+ "road_default": "#C9C0C9"
15
+ }
themes/sunset.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Sunset",
3
+ "description": "Warm oranges and pinks on soft peach - dreamy golden hour aesthetic",
4
+ "bg": "#FDF5F0",
5
+ "text": "#C45C3E",
6
+ "gradient_color": "#FDF5F0",
7
+ "water": "#F0D8D0",
8
+ "parks": "#F8E8E0",
9
+ "road_motorway": "#C45C3E",
10
+ "road_primary": "#D87A5A",
11
+ "road_secondary": "#E8A088",
12
+ "road_tertiary": "#F0B8A8",
13
+ "road_residential": "#F5D0C8",
14
+ "road_default": "#E8A088"
15
+ }
themes/terracotta.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Terracotta",
3
+ "description": "Mediterranean warmth - burnt orange and clay tones on cream",
4
+ "bg": "#F5EDE4",
5
+ "text": "#8B4513",
6
+ "gradient_color": "#F5EDE4",
7
+ "water": "#A8C4C4",
8
+ "parks": "#E8E0D0",
9
+ "road_motorway": "#8B3E2F",
10
+ "road_primary": "#A0522D",
11
+ "road_secondary": "#B8653A",
12
+ "road_tertiary": "#C9846A",
13
+ "road_residential": "#B59A8B",
14
+ "road_default": "#C9846A"
15
+ }
themes/warm_beige.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Warm Beige",
3
+ "description": "Earthy warm neutrals with sepia tones - vintage map aesthetic",
4
+ "bg": "#F5F0E8",
5
+ "text": "#6B5B4F",
6
+ "gradient_color": "#F5F0E8",
7
+ "water": "#DDD5C8",
8
+ "parks": "#E8E4D8",
9
+ "road_motorway": "#8B7355",
10
+ "road_primary": "#A08B70",
11
+ "road_secondary": "#B5A48E",
12
+ "road_tertiary": "#C9BBAA",
13
+ "road_residential": "#D9CFC2",
14
+ "road_default": "#C9BBAA"
15
+ }
uv.lock ADDED
The diff for this file is too large to render. See raw diff