diff --git a/README.md b/README.md index e693c0f7..fdd84c81 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,17 @@ python create_map_poster.py --city --country [options] | Option | Short | Description | Default | |--------|-------|-------------|---------| -| **OPTIONAL:** `--latitude` | `-lat` | Override latitude center point (use with --longitude) | | -| **OPTIONAL:** `--longitude` | `-long` | Override longitude center point (use with --latitude) | | -| **OPTIONAL:** `--country-label` | | Override country text displayed on poster | | -| **OPTIONAL:** `--theme` | `-t` | Theme name | terracotta | -| **OPTIONAL:** `--distance` | `-d` | Map radius in meters | 18000 | -| **OPTIONAL:** `--list-themes` | | List all available themes | | -| **OPTIONAL:** `--all-themes` | | Generate posters for all available themes | | -| **OPTIONAL:** `--width` | `-W` | Image width in inches | 12 (max: 20) | -| **OPTIONAL:** `--height` | `-H` | Image height in inches | 16 (max: 20) | +| `--latitude` | `-lat` | Override latitude center point (use with --longitude) | | +| `--longitude` | `-long` | Override longitude center point (use with --latitude) | | +| `--country-label` | | Override country text displayed on poster | | +| `--theme` | `-t` | Theme name | `terracotta` | +| `--distance` | `-d` | Map radius in meters | `18000` | +| `--list-themes` | | List all available themes | | +| `--all-themes` | | Generate posters for all available themes | | +| `--width` | `-W` | Image width in inches | `12` (max: 20) | +| `--height` | `-H` | Image height in inches | `16` (max: 20) | +| `--map-x-offset` | `-mx` | Shift viewport left/right (−1.0 to +1.0) | `0.0` | +| `--map-y-offset` | `-my` | Shift viewport up/down (−1.0 to +1.0) | `0.0` | ### Multilingual Support - i18n @@ -90,20 +92,7 @@ Display city and country names in your language with custom fonts from google fo | `--display-country` | `-dC` | Custom display name for country (e.g., "日本") | | `--font-family` | | Google Fonts family name (e.g., "Noto Sans JP") | -**Examples:** - -```bash -# Japanese -python create_map_poster.py -c "Tokyo" -C "Japan" -dc "東京" -dC "日本" --font-family "Noto Sans JP" - -# Korean -python create_map_poster.py -c "Seoul" -C "South Korea" -dc "서울" -dC "대한민국" --font-family "Noto Sans KR" - -# Arabic -python create_map_poster.py -c "Dubai" -C "UAE" -dc "دبي" -dC "الإمارات" --font-family "Cairo" -``` - -**Note**: Fonts are automatically downloaded from Google Fonts and cached locally in `fonts/cache/`. +**Note**: Fonts are automatically downloaded from Google Fonts and cached locally in `fonts/cache/`. See [Multilingual Examples](#multilingual-examples-non-latin-scripts) below for full usage. ### Resolution Guide (300 DPI) @@ -146,8 +135,8 @@ python create_map_poster.py -c "Bangkok" -C "Thailand" -dc "กรุงเท # Arabic python create_map_poster.py -c "Dubai" -C "UAE" -dc "دبي" -dC "الإمارات" --font-family "Cairo" -t terracotta -# Chinese (Simplified) -python create_map_poster.py -c "Beijing" -C "China" -dc "北京" -dC "中国" --font-family "Noto Sans SC" +# Chinese (Simplified) — with viewport offset to shift map center +python create_map_poster.py -c "Beijing" -C "China" -dc "北京" -dC "中国" --font-family "Noto Sans SC" -t contrast_zones -d 25000 # Khmer python create_map_poster.py -c "Phnom Penh" -C "Cambodia" -dc "ភ្នំពេញ" -dC "កម្ពុជា" --font-family "Noto Sans Khmer" @@ -186,6 +175,12 @@ python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Override center coordinates python create_map_poster.py --city "New York" --country "USA" -lat 40.776676 -long -73.971321 -t noir +# Chinese cities with new themes +python create_map_poster.py -c "Shanghai" -C "China" -dc "上海" -dC "中国" --font-family "Noto Sans SC" -t neon_cyberpunk -d 20000 +python create_map_poster.py -c "Lhasa" -C "China" -dc "拉萨" -dC "西藏" --font-family "Noto Sans SC" -t tibetan_sky -d 20000 +python create_map_poster.py -c "Guyuan" -C "China" -dc "固原" -dC "宁夏" --font-family "Noto Sans SC" -t loess_jin -d 30000 -mx 0.167 +python create_map_poster.py -c "Qinhuangdao" -C "China" -dc "秦皇岛" -dC "河北" --font-family "Noto Sans SC" -t qinhuangdao_coast -d 40000 -my 0.25 + # List available themes python create_map_poster.py --list-themes @@ -203,7 +198,9 @@ python create_map_poster.py -c "Tokyo" -C "Japan" --all-themes ## Themes -17 themes available in `themes/` directory: +**47 themes** available in `themes/` directory (17 built-in + 30 Chinese city themes). + +### Built-in Themes (17) | Theme | Style | |-------|-------| @@ -225,6 +222,12 @@ python create_map_poster.py -c "Tokyo" -C "Japan" --all-themes | `copper_patina` | Oxidized copper aesthetic | | `monochrome_blue` | Single blue color family | +### Chinese City Themes (30) + +Curated themes for Chinese geography and culture: + +`tibetan_sky` · `lhasa_crimson` · `gongga_glacial` · `peach_spring` · `steppe_sky` · `datong_coal` · `loess_jin` · `loess_gobi` · `jinshan_gold` · `guandi_red` · `tang_dynasty` · `ming_purple` · `sichuan_spice` · `mountain_city` · `shenzhen_tech` · `victoria_harbour` · `taihu_ink` · `min_river` · `xiamen_sea` · `lushan_mist` · `qinhuangdao_coast` · `chengde_forest` · `haihe_night` · `tengger_sand` · `spring_youth` · `grassland_blueprint` · `zhao_bronze` · `putuo_zen` · `winter_peaks` · `steppe_silver` + ## Output Posters are saved to `posters/` directory with format: @@ -251,10 +254,24 @@ Create a JSON file in `themes/` directory: "road_secondary": "#2A2A2A", "road_tertiary": "#3A3A3A", "road_residential": "#4A4A4A", - "road_default": "#3A3A3A" + "road_default": "#3A3A3A", + + "road_width_scale": 1.5, + "bg_patina": true, + "bg_patina_color": "#40A880", + "bg_top": "#1A3A6A", + "bg_bottom": "#0A1A3A" } ``` +| New Key | Type | Default | Description | +|---------|------|---------|-------------| +| `road_width_scale` | float | `1.0` | Multiply all road stroke widths | +| `bg_patina` | bool | `false` | Subtle aged-texture overlay on background | +| `bg_patina_color` | hex | `"#40A880"` | Tint color for patina (requires `bg_patina: true`) | +| `bg_top` | hex | — | Top color of vertical background gradient | +| `bg_bottom` | hex | — | Bottom color of vertical background gradient | + ## Project Structure ```text diff --git a/create_map_poster.py b/create_map_poster.py index 3eab412b..10b55e9d 100755 --- a/create_map_poster.py +++ b/create_map_poster.py @@ -10,6 +10,7 @@ import argparse import asyncio import json +import math import os import pickle import sys @@ -32,6 +33,15 @@ from font_management import load_fonts +# Configure osmnx to respect HTTP_PROXY / HTTPS_PROXY environment variables +# (needed for SOCKS5 proxies in corporate/school networks) +_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") +if _proxy: + ox.settings.requests_kwargs = {"proxies": {"http": _proxy, "https": _proxy}} +ox.settings.timeout = 600 # Overpass QL server-side [timeout:N] param +ox.settings.requests_timeout = 600 # HTTP client read timeout (actual network timeout) +ox.settings.overpass_rate_limit = False # Skip /status check — avoids hanging on status endpoint + class CacheError(Exception): """Raised when a cache operation fails.""" @@ -199,7 +209,7 @@ def load_theme(theme_name="terracotta"): "road_default": "#D9A08A", } - with open(theme_file, "r", encoding=FILE_ENCODING) as f: + with open(theme_file, "r", encoding="utf-8-sig") as f: theme = json.load(f) print(f"✓ Loaded theme: {theme.get('name', theme_name)}") if "description" in theme: @@ -213,42 +223,121 @@ def load_theme(theme_name="terracotta"): def create_gradient_fade(ax, color, location="bottom", zorder=10): """ - Creates a fade effect at the top or bottom of the map. + Creates a perfectly smooth fade effect at the top or bottom of the map. + Uses a direct RGBA image array (no colormap quantisation) for banding-free gradients. """ - vals = np.linspace(0, 1, 256).reshape(-1, 1) - gradient = np.hstack((vals, vals)) - + N = 1024 # rows in the gradient image — high enough for smooth output rgb = mcolors.to_rgb(color) - my_colors = np.zeros((256, 4)) - my_colors[:, 0] = rgb[0] - my_colors[:, 1] = rgb[1] - my_colors[:, 2] = rgb[2] + + # Build RGBA image: shape (N, 2, 4) + img = np.zeros((N, 2, 4), dtype=np.float32) + img[:, :, 0] = rgb[0] + img[:, :, 1] = rgb[1] + img[:, :, 2] = rgb[2] if location == "bottom": - my_colors[:, 3] = np.linspace(1, 0, 256) - extent_y_start = 0 + # bottom: opaque → transparent (rows 0..N-1 map to alpha 1..0) + alpha = np.linspace(1.0, 0.0, N) + extent_y_start = 0.0 extent_y_end = 0.25 else: - my_colors[:, 3] = np.linspace(0, 1, 256) + # top: transparent → opaque + alpha = np.linspace(0.0, 1.0, N) extent_y_start = 0.75 extent_y_end = 1.0 - custom_cmap = mcolors.ListedColormap(my_colors) + img[:, :, 3] = alpha[:, np.newaxis] xlim = ax.get_xlim() ylim = ax.get_ylim() y_range = ylim[1] - ylim[0] - y_bottom = ylim[0] + y_range * extent_y_start - y_top = ylim[0] + y_range * extent_y_end + y_top = ylim[0] + y_range * extent_y_end ax.imshow( - gradient, + img, extent=[xlim[0], xlim[1], y_bottom, y_top], aspect="auto", - cmap=custom_cmap, zorder=zorder, origin="lower", + interpolation="bilinear", + ) + + +def create_patina_texture(ax, base_color, zorder=0.05, seed=42): + """ + Overlays an organic mottled patina / verdigris texture on the background. + Uses Gaussian-blurred multi-frequency noise to create natural, irregular + blotch shapes (no grid/block artefacts). + + base_color : hex/name – the colour tint of the patina patches + """ + from scipy.ndimage import gaussian_filter + + rng = np.random.default_rng(seed) + H, W = 768, 576 # internal texture resolution + + # Two large-scale layers only — wide sigma for broad organic blotches + layer_huge = gaussian_filter(rng.random((H, W)).astype(np.float32), sigma=120) + layer_large = gaussian_filter(rng.random((H, W)).astype(np.float32), sigma=50) + + combined = layer_huge * 0.70 + layer_large * 0.30 + + # Normalise to [0, 1] + lo, hi = combined.min(), combined.max() + combined = (combined - lo) / (hi - lo + 1e-8) + + # Threshold: only keep the top ~50% so patches are distinct, not a wash + combined = np.clip((combined - 0.45) / 0.55, 0, 1) + + # Build RGBA image: base_color modulates the tint, alpha carries the mask + r, g, b = mcolors.to_rgb(base_color) + tex = np.zeros((H, W, 4), dtype=np.float32) + tex[:, :, 0] = r + tex[:, :, 1] = g + tex[:, :, 2] = b + tex[:, :, 3] = combined * 0.30 # max 30% opacity — subtle but visible + + xlim = ax.get_xlim() + ylim = ax.get_ylim() + ax.imshow( + tex, + extent=[xlim[0], xlim[1], ylim[0], ylim[1]], + aspect="auto", + zorder=zorder, + origin="upper", + interpolation="bilinear", + ) + + +def create_bg_gradient(ax, color_top, color_bottom, zorder=0.1): + """ + Creates a full-canvas vertical background gradient. + color_top is drawn at the top of the plot, color_bottom at the bottom. + Uses a low zorder so it sits above the solid facecolor but below all map layers. + """ + n = 2048 + # shape (n_rows, 2_cols, 4_channels); row 0 = bottom of image (origin='lower') + t_vals = np.linspace(0.0, 1.0, n) # 0 = bottom colour, 1 = top colour + + rgb_top = np.array(mcolors.to_rgb(color_top), dtype=np.float32) + rgb_bot = np.array(mcolors.to_rgb(color_bottom), dtype=np.float32) + + gradient_data = np.zeros((n, 2, 4), dtype=np.float32) + gradient_data[:, :, :3] = (rgb_bot[None, None, :] * (1 - t_vals[:, None, None]) + + rgb_top[None, None, :] * t_vals[:, None, None]) + gradient_data[:, :, 3] = 1.0 # fully opaque + + xlim = ax.get_xlim() + ylim = ax.get_ylim() + + ax.imshow( + gradient_data, + extent=[xlim[0], xlim[1], ylim[0], ylim[1]], + aspect="auto", + zorder=zorder, + origin="lower", + interpolation="bilinear", ) @@ -290,8 +379,10 @@ def get_edge_widths_by_type(g): """ Assigns line widths to edges based on road type. Major roads get thicker lines. + Multiplied by THEME['road_width_scale'] if set. """ edge_widths = [] + scale = float(THEME.get("road_width_scale", 1.0)) for _u, _v, data in g.edges(data=True): highway = data.get('highway', 'unclassified') @@ -311,7 +402,7 @@ def get_edge_widths_by_type(g): else: width = 0.4 - edge_widths.append(width) + edge_widths.append(width * scale) return edge_widths @@ -370,10 +461,16 @@ def get_coordinates(city, country): raise ValueError(f"Could not find coordinates for {city}, {country}") -def get_crop_limits(g_proj, center_lat_lon, fig, dist): +def get_crop_limits(g_proj, center_lat_lon, fig, dist, text_area_frac: float = 0.05, + x_offset_frac: float = 0.0, y_offset_frac: float = 0.0): """ Crop inward to preserve aspect ratio while guaranteeing full coverage of the requested radius. + + text_area_frac: fraction of figure height to compensate for bottom text block. + 0.05 = center at y=52.5% (default, slight upward bias). + x_offset_frac: positive → crop window shifts LEFT → geo-centre moves RIGHT in poster. + y_offset_frac: positive → crop window shifts UP → geo-centre moves DOWN in poster. """ lat, lon = center_lat_lon @@ -400,9 +497,17 @@ def get_crop_limits(g_proj, center_lat_lon, fig, dist): else: # portrait → reduce width half_x = half_y * aspect + # Shift the crop window downward so the geographic centre sits at the + # visual mid-point of the area above the bottom text block. + # text_area_frac: negative y_shift raises centre slightly above 50%. + # y_offset_frac: positive y_shift pushes window upward → centre appears lower. + # x_offset_frac: negative x_shift slides window leftward → centre appears righter. + y_shift = -half_y * text_area_frac + half_y * y_offset_frac + x_shift = -half_x * x_offset_frac + return ( - (center_x - half_x, center_x + half_x), - (center_y - half_y, center_y + half_y), + (center_x - half_x + x_shift, center_x + half_x + x_shift), + (center_y - half_y + y_shift, center_y + half_y + y_shift), ) @@ -427,6 +532,11 @@ def fetch_graph(point, dist) -> MultiDiGraph | None: print("✓ Using cached street network") return cast(MultiDiGraph, cached) + # Apply a hard read-timeout so slow Overpass streams don't hang forever. + # ox.settings.requests_timeout is the actual HTTP request timeout used by osmnx. + # ox.settings.timeout only controls the Overpass QL [timeout:N] server-side setting. + original_requests_timeout = ox.settings.requests_timeout + ox.settings.requests_timeout = 600 # 10 minutes max for street network try: g = ox.graph_from_point(point, dist=dist, dist_type='bbox', network_type='all', truncate_by_edge=True) # Rate limit between requests @@ -437,11 +547,19 @@ def fetch_graph(point, dist) -> MultiDiGraph | None: print(e) return g except Exception as e: - print(f"OSMnx error while fetching graph: {e}") + err_str = str(e).lower() + if any(kw in err_str for kw in ("timed out", "timeout", "read timed", + "time out", "readtimeout", "connectiontimeout")): + print(f"⚠ Street network download timed out — skipping (poster will render without roads)") + else: + print(f"OSMnx error while fetching graph: {e}") return None + finally: + ox.settings.requests_timeout = original_requests_timeout -def fetch_features(point, dist, tags, name) -> GeoDataFrame | None: +def fetch_features(point, dist, tags, name, max_dist: int | None = None, + timeout: int | None = None) -> GeoDataFrame | None: """ Fetch geographic features (water, parks, etc.) from OpenStreetMap. @@ -453,30 +571,79 @@ def fetch_features(point, dist, tags, name) -> GeoDataFrame | None: dist: Distance in meters from center point tags: Dictionary of OSM tags to filter features name: Name for this feature type (for caching and logging) + timeout: Optional per-request timeout in seconds. If specified, overrides + the global ox.settings.timeout for this fetch only, and also + injects a read-timeout into requests_kwargs so that slow/hung + HTTP responses are aborted. If the request times out the layer + is skipped gracefully. Returns: GeoDataFrame of features, or None if fetch fails """ lat, lon = point + effective_dist = min(dist, max_dist) if max_dist is not None else dist tag_str = "_".join(tags.keys()) - features = f"{name}_{lat}_{lon}_{dist}_{tag_str}" + features = f"{name}_{lat}_{lon}_{effective_dist}_{tag_str}" cached = cache_get(features) if cached is not None: print(f"✓ Using cached {name}") return cast(GeoDataFrame, cached) + # Temporarily override osmnx HTTP timeout. + # ox.settings.requests_timeout is the actual HTTP request timeout. + # ox.settings.timeout only sets the Overpass QL [timeout:N] server-side param. + original_requests_timeout = ox.settings.requests_timeout + if timeout is not None: + ox.settings.requests_timeout = timeout + + def _do_fetch() -> GeoDataFrame: + return ox.features_from_point(point, tags=tags, dist=effective_dist) + try: - data = ox.features_from_point(point, tags=tags, dist=dist) - # Rate limit between requests - time.sleep(0.3) - try: - cache_set(features, data) - except CacheError as e: - print(e) - return data - except Exception as e: - print(f"OSMnx error while fetching features: {e}") - return None + for attempt in range(2): # 2 attempts when timeout is short + try: + if timeout is not None: + # Use thread-based wall-clock timeout to cut off hung Overpass streams. + # shutdown(wait=False) so we don't block waiting for the background + # thread even after the timeout fires. + import concurrent.futures + executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + try: + future = executor.submit(_do_fetch) + try: + data = future.result(timeout=timeout) + except concurrent.futures.TimeoutError: + print(f"⚠ {name} download timed out — skipping layer (poster will render without it)") + executor.shutdown(wait=False) + return None + finally: + executor.shutdown(wait=False) + else: + data = _do_fetch() + # Rate limit between requests + time.sleep(0.3) + try: + cache_set(features, data) + except CacheError as e: + print(e) + return data + except Exception as e: + err_str = str(e).lower() + # Detect timeout-related errors — treat as non-retriable + if any(kw in err_str for kw in ("timed out", "timeout", "read timed", + "time out", "readtimeout", "connectiontimeout")): + print(f"⚠ {name} download timed out — skipping layer (poster will render without it)") + return None + if attempt < 1: + print(f"OSMnx retry {attempt+1}/2 for {name}: {e}") + time.sleep(5 * (attempt + 1)) + else: + print(f"OSMnx error while fetching features: {e}") + return None + finally: + # Always restore the original settings + ox.settings.requests_timeout = original_requests_timeout + return None def create_poster( @@ -493,6 +660,8 @@ def create_poster( display_city=None, display_country=None, fonts=None, + map_x_offset=0.0, + map_y_offset=0.0, ): """ Generate a complete map poster with roads, water, parks, and typography. @@ -532,7 +701,21 @@ def create_poster( # 1. Fetch Street Network pbar.set_description("Downloading street network") compensated_dist = dist * (max(height, width) / min(height, width)) / 4 # To compensate for viewport crop - g = fetch_graph(point, compensated_dist) + # When map is offset, shift the fetch center so the offset region is covered. + # map_y_offset > 0 means content shifts DOWN → viewport looks NORTH → move fetch center north. + # map_x_offset > 0 means content shifts RIGHT → viewport looks WEST → move fetch center west. + lat_shift_m = map_y_offset * compensated_dist # positive y_offset → look north → lat+ + lon_shift_m = -map_x_offset * compensated_dist # positive x_offset → look west → lon- + meters_per_deg_lat = 111320.0 + meters_per_deg_lon = 111320.0 * abs(math.cos(math.radians(point[0]))) + fetch_point = ( + point[0] + lat_shift_m / meters_per_deg_lat, + point[1] + lon_shift_m / meters_per_deg_lon, + ) + # Also expand radius slightly to ensure full coverage around shifted center + offset_extra = max(abs(map_x_offset), abs(map_y_offset)) * 0.5 + fetch_dist = compensated_dist * (1 + offset_extra) + g = fetch_graph(fetch_point, fetch_dist) if g is None: raise RuntimeError("Failed to retrieve street network data.") pbar.update(1) @@ -540,29 +723,34 @@ def create_poster( # 2. Fetch Water Features pbar.set_description("Downloading water features") water = fetch_features( - point, - compensated_dist, + fetch_point, + fetch_dist, tags={"natural": ["water", "bay", "strait"], "waterway": "riverbank"}, name="water", + timeout=60, ) pbar.update(1) # 3. Fetch Parks pbar.set_description("Downloading parks/green spaces") parks = fetch_features( - point, - compensated_dist, + fetch_point, + fetch_dist, tags={"leisure": "park", "landuse": "grass"}, name="parks", + max_dist=4000, + timeout=60, ) pbar.update(1) + print("✓ All data retrieved successfully!") # 2. Setup Plot print("Rendering map...") - fig, ax = plt.subplots(figsize=(width, height), facecolor=THEME["bg"]) - ax.set_facecolor(THEME["bg"]) + bg_color = THEME["bg"] + fig, ax = plt.subplots(figsize=(width, height), facecolor=bg_color) + ax.set_facecolor(bg_color) ax.set_position((0.0, 0.0, 1.0, 1.0)) # Project graph to a metric CRS so distances and aspect are linear (meters) @@ -597,10 +785,12 @@ def create_poster( edge_widths = get_edge_widths_by_type(g_proj) # Determine cropping limits to maintain the poster aspect ratio - crop_xlim, crop_ylim = get_crop_limits(g_proj, point, fig, compensated_dist) + crop_xlim, crop_ylim = get_crop_limits(g_proj, point, fig, compensated_dist, + x_offset_frac=map_x_offset, + y_offset_frac=map_y_offset) # Plot the projected graph and then apply the cropped limits ox.plot_graph( - g_proj, ax=ax, bgcolor=THEME['bg'], + g_proj, ax=ax, bgcolor=bg_color, node_size=0, edge_color=edge_colors, edge_linewidth=edge_widths, @@ -611,9 +801,22 @@ def create_poster( ax.set_xlim(crop_xlim) ax.set_ylim(crop_ylim) + # Optional background gradient (triggered by bg_top + bg_bottom in theme) + if THEME.get("bg_top") and THEME.get("bg_bottom"): + create_bg_gradient(ax, THEME["bg_top"], THEME["bg_bottom"], zorder=0.1) + + # Optional patina / mottled texture overlay (triggered by bg_patina in theme) + if THEME.get("bg_patina"): + patina_color = THEME.get("bg_patina_color") or THEME.get("road_motorway") or "#40A880" + create_patina_texture(ax, patina_color, zorder=0.08) + # Layer 3: Gradients (Top and Bottom) - create_gradient_fade(ax, THEME['gradient_color'], location='bottom', zorder=10) - create_gradient_fade(ax, THEME['gradient_color'], location='top', zorder=10) + # When bg_top/bg_bottom are set, use them for the fade so colours are consistent + _default_fade = THEME["gradient_color"] + fade_top_color = THEME.get("bg_top") or _default_fade + fade_bottom_color = THEME.get("bg_bottom") or _default_fade + create_gradient_fade(ax, fade_bottom_color, location='bottom', zorder=10) + create_gradient_fade(ax, fade_top_color, location='top', zorder=10) # Calculate scale factor based on smaller dimension (reference 12 inches) # This ensures text scales properly for both portrait and landscape orientations @@ -843,7 +1046,7 @@ def list_themes(): for theme_name in available_themes: theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json") try: - with open(theme_path, "r", encoding=FILE_ENCODING) as f: + with open(theme_path, "r", encoding="utf-8-sig") as f: theme_data = json.load(f) display_name = theme_data.get('name', theme_name) description = theme_data.get('description', '') @@ -955,6 +1158,22 @@ def list_themes(): choices=["png", "svg", "pdf"], help="Output format for the poster (default: png)", ) + parser.add_argument( + "--map-x-offset", + "-mx", + dest="map_x_offset", + type=float, + default=0.0, + help="Horizontal map offset fraction (positive → content shifts right in poster, default: 0.0)", + ) + parser.add_argument( + "--map-y-offset", + "-my", + dest="map_y_offset", + type=float, + default=0.0, + help="Vertical map offset fraction (positive → content shifts down in poster, default: 0.0)", + ) args = parser.parse_args() @@ -1037,6 +1256,8 @@ def list_themes(): display_city=args.display_city, display_country=args.display_country, fonts=custom_fonts, + map_x_offset=args.map_x_offset, + map_y_offset=args.map_y_offset, ) print("\n" + "=" * 50) diff --git a/font_management.py b/font_management.py index 1c738d83..0fb3a37c 100644 --- a/font_management.py +++ b/font_management.py @@ -34,6 +34,34 @@ def download_google_font(font_family: str, weights: list = None) -> Optional[dic font_files = {} + # --- Fast path: if all weight files already in cache, skip API request --- + weight_map_early = {300: "light", 400: "regular", 700: "bold"} + all_cached = True + for w in weights: + key = weight_map_early.get(w, "regular") + # Try both .ttf and .woff2 + found = False + for ext in ("ttf", "woff2"): + fp = FONTS_CACHE_DIR / f"{font_name_safe}_{key}.{ext}" + if fp.exists(): + font_files[key] = str(fp) + found = True + break + if not found: + all_cached = False + break + + if all_cached and len(font_files) >= len(weights): + for key in font_files: + print(f" Using cached {font_family} {key}") + # Fill in missing weight aliases + if "bold" not in font_files and "regular" in font_files: + font_files["bold"] = font_files["regular"] + if "light" not in font_files and "regular" in font_files: + font_files["light"] = font_files["regular"] + return font_files + # ------------------------------------------------------------------------- + try: # Google Fonts API endpoint - request all weights at once weights_str = ";".join(map(str, weights)) diff --git a/themes/chengde_forest.json b/themes/chengde_forest.json new file mode 100644 index 00000000..f44dea3c --- /dev/null +++ b/themes/chengde_forest.json @@ -0,0 +1 @@ +{"_name":"Grassland Fortress","bg":"#0A2208","gradient_color":"#0A2208","text":"#E8FFE0","water":"#2880C0","parks":"#1A5828","bg_patina":false,"road_motorway":"#B0B0B0","road_primary":"#909090","road_secondary":"#727272","road_tertiary":"#585858","road_residential":"#444444","road_default":"#363636","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/datong_coal.json b/themes/datong_coal.json new file mode 100644 index 00000000..a6203509 --- /dev/null +++ b/themes/datong_coal.json @@ -0,0 +1 @@ +{"_name":"Datong Coal","_description":"Datong Shanxi - coal-black background evoking centuries of coal mining, ash-gray road network like cinders and soot, deep steel-blue waterways, smoldering ember text","bg":"#080806","gradient_color":"#080806","text":"#C8C0B0","water":"#1A3050","parks":"#181C12","bg_patina":false,"road_motorway":"#FFD840","road_primary":"#E8B820","road_secondary":"#C49810","road_tertiary":"#9A7808","road_residential":"#705808","road_default":"#4A3C08","road_width_scale":2.0} diff --git a/themes/gongga_glacial.json b/themes/gongga_glacial.json new file mode 100644 index 00000000..027fba52 --- /dev/null +++ b/themes/gongga_glacial.json @@ -0,0 +1,16 @@ +{ + "name": "Gongga Glacial", + "description": "Garzê Tibetan Autonomous Prefecture Sichuan — deep midnight-blue glacial high altitude background, vivid glacier ice-blue waterways evoking meltwater from Gongga and Haizi Mountain snowfields, cool pale jade-green alpine meadow parks, silver-white glacial road network of ancient tea-horse caravan routes, crisp white text", + "bg": "#060C14", + "gradient_color": "#060C14", + "text": "#E8F4FF", + "water": "#1870C0", + "parks": "#0E3820", + "road_motorway": "#C8E8FF", + "road_primary": "#A0C8E8", + "road_secondary": "#78A8C8", + "road_tertiary": "#5888A8", + "road_residential": "#3A6888", + "road_default": "#487898", + "road_width_scale": 1.5 +} diff --git a/themes/grassland_blueprint.json b/themes/grassland_blueprint.json new file mode 100644 index 00000000..9cfd0282 --- /dev/null +++ b/themes/grassland_blueprint.json @@ -0,0 +1,15 @@ +{ + "name": "Grassland Blueprint", + "description": "Inner Mongolian steppe as a blueprint — deep forest dark as the base, glowing grass-green roads from sunlit motorway shimmer down to shadowed meadow lanes", + "bg": "#1A3C1A", + "text": "#E0F4D0", + "gradient_color": "#1A3C1A", + "water": "#102812", + "parks": "#204C20", + "road_motorway": "#D8F2B8", + "road_primary": "#B0D888", + "road_secondary": "#88BC60", + "road_tertiary": "#64A040", + "road_residential": "#488830", + "road_default": "#64A040" +} diff --git a/themes/guandi_red.json b/themes/guandi_red.json new file mode 100644 index 00000000..23e22926 --- /dev/null +++ b/themes/guandi_red.json @@ -0,0 +1,18 @@ +{ + "name": "Guandi Red", + "description": "Yuncheng Shanxi — birthplace of Guan Yu, God of War and Commerce. Deep near-black background, vivid vermilion Guandi temple red water and parks, blazing golden road network evoking the legend of loyalty and righteousness, parchment gold text", + "bg": "#0E0806", + "gradient_color": "#0E0806", + "text": "#F0D880", + "water": "#C02020", + "parks": "#881818", + "bg_patina": true, + "bg_patina_color": "#8A2A18", + "road_motorway": "#F0B000", + "road_primary": "#D09000", + "road_secondary": "#B07800", + "road_tertiary": "#906000", + "road_residential": "#704800", + "road_default": "#805400", + "road_width_scale": 1.5 +} diff --git a/themes/haihe_night.json b/themes/haihe_night.json new file mode 100644 index 00000000..0a9e00dd --- /dev/null +++ b/themes/haihe_night.json @@ -0,0 +1 @@ +{"_name":"Haihe Night Glow","bg":"#030A18","gradient_color":"#030A18","text":"#E8F0FF","water":"#1A70E8","parks":"#1A4030","bg_patina":false,"road_motorway":"#FFD840","road_primary":"#E8C030","road_secondary":"#C8A020","road_tertiary":"#A88010","road_residential":"#806010","road_default":"#5A4008","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/jinshan_gold.json b/themes/jinshan_gold.json new file mode 100644 index 00000000..bb839443 --- /dev/null +++ b/themes/jinshan_gold.json @@ -0,0 +1,17 @@ +{ + "_name": "Jinshan Gold", + "_description": "Taiyuan, Shanxi — deep vermilion-black background evoking ancient Jin culture and mountain temples, brilliant gold road network like sunset over the Taihang Mountains, crimson waterways echoing the Fen River", + "bg": "#1A0808", + "gradient_color": "#1A0808", + "text": "#F8D880", + "water": "#C82828", + "parks": "#1E4018", + "bg_patina": true, + "road_motorway": "#FFD040", + "road_primary": "#E0B030", + "road_secondary": "#C09020", + "road_tertiary": "#A07018", + "road_residential": "#805010", + "road_default": "#604010", + "road_width_scale": 2.0 +} diff --git a/themes/lhasa_crimson.json b/themes/lhasa_crimson.json new file mode 100644 index 00000000..d47dd151 --- /dev/null +++ b/themes/lhasa_crimson.json @@ -0,0 +1,18 @@ +{ + "name": "Lhasa Crimson", + "description": "Chamdo Kham Tibet — deep ink-black high plateau night, vivid Potala Palace crimson-red waterways evoking the red walls of sacred temples, blazing golden road network of pilgrimage routes, mottled aged plaster patina texture, bright gold text", + "bg": "#0A0806", + "gradient_color": "#0A0806", + "text": "#F4D860", + "water": "#BB2020", + "parks": "#7A1818", + "bg_patina": false, + "bg_patina_color": "#7A2010", + "road_motorway": "#F2C000", + "road_primary": "#D4A000", + "road_secondary": "#B08200", + "road_tertiary": "#906800", + "road_residential": "#704E00", + "road_default": "#805800", + "road_width_scale": 1.5 +} diff --git a/themes/loess_gobi.json b/themes/loess_gobi.json new file mode 100644 index 00000000..ddf2b2ef --- /dev/null +++ b/themes/loess_gobi.json @@ -0,0 +1,15 @@ +{ + "name": "Loess Gobi", + "description": "Arid northwest China — bleached desert sand, sparse Gobi roads, warm sun over wide open land", + "bg": "#F0E5C0", + "text": "#8C8272", + "gradient_color": "#DED0A0", + "water": "#7A9FB5", + "parks": "#C8BD80", + "road_motorway": "#C4850A", + "road_primary": "#D4970A", + "road_secondary": "#E0AD30", + "road_tertiary": "#DBBB52", + "road_residential": "#D8C880", + "road_default": "#CCBA70" +} diff --git a/themes/loess_jin.json b/themes/loess_jin.json new file mode 100644 index 00000000..47bcbbcd --- /dev/null +++ b/themes/loess_jin.json @@ -0,0 +1,18 @@ +{ + "name": "Loess Jin", + "description": "Jinzhong Shanxi — warm loess-earth dark background, organic mottled yellow-ochre patina evoking ancient rammed earth walls, deep indigo waterways, bright loess-yellow golden road network tracing Jin merchant routes, parchment text", + "bg": "#120E08", + "gradient_color": "#120E08", + "text": "#E8D0A0", + "water": "#1A2D4A", + "parks": "#201C08", + "bg_patina": true, + "bg_patina_color": "#8A6428", + "road_motorway": "#E0A020", + "road_primary": "#C08820", + "road_secondary": "#A07020", + "road_tertiary": "#805A18", + "road_residential": "#604818", + "road_default": "#705418", + "road_width_scale": 1.5 +} diff --git a/themes/lotus_heritage.json b/themes/lotus_heritage.json new file mode 100644 index 00000000..302e59ec --- /dev/null +++ b/themes/lotus_heritage.json @@ -0,0 +1,16 @@ +{ + "name": "Lotus Heritage", + "description": "Baoding ancient capital — deep indigo ink night, warm amber lamplight on historic streets, lotus pond jade water, vermillion city gates", + "bg": "#12131F", + "text": "#F0E8C8", + "gradient_color": "#12131F", + "water": "#1C3A52", + "parks": "#142A1A", + "road_motorway": "#E8A840", + "road_primary": "#D49830", + "road_secondary": "#C08820", + "road_tertiary": "#A87018", + "road_residential": "#8C5C14", + "road_default": "#9A6818", + "road_width_scale": 1.6 +} diff --git a/themes/lushan_mist.json b/themes/lushan_mist.json new file mode 100644 index 00000000..4863b22e --- /dev/null +++ b/themes/lushan_mist.json @@ -0,0 +1,17 @@ +{ + "_name": "Lushan Mist", + "_description": "Jiangxi Lushan mountain — ink-washed misty deep forest-green background, vivid verdant mountain park mass clearly visible as the mountain body, bright Poyang Lake cerulean waterways, soft mint-mist road network, crisp celadon text", + "bg": "#0C2C10", + "gradient_color": "#0C2C10", + "text": "#C8ECD8", + "water": "#1C78A8", + "parks": "#6AC858", + "bg_patina": false, + "road_motorway": "#A0D8B8", + "road_primary": "#80B898", + "road_secondary": "#609878", + "road_tertiary": "#487860", + "road_residential": "#365848", + "road_default": "#3D6050", + "road_width_scale": 1.5 +} diff --git a/themes/min_river.json b/themes/min_river.json new file mode 100644 index 00000000..a40815a5 --- /dev/null +++ b/themes/min_river.json @@ -0,0 +1 @@ +{"_name":"Min River","bg":"#081C0C","gradient_color":"#081C0C","text":"#F0F8E8","water":"#1890D0","parks":"#1E5030","bg_patina":false,"road_motorway":"#D8C880","road_primary":"#C0A860","road_secondary":"#A08840","road_tertiary":"#887028","road_residential":"#6A5818","road_default":"#504010","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/ming_purple.json b/themes/ming_purple.json new file mode 100644 index 00000000..282582cb --- /dev/null +++ b/themes/ming_purple.json @@ -0,0 +1,16 @@ +{ + "name": "Ming Dynasty Purple", + "description": "Nanjing ancient capital — deep imperial purple night on the Purple Mountain, jade Xuanwu Lake water, Ming city wall silhouette, warm gold palace road veins", + "bg": "#130C1E", + "text": "#EDD8A0", + "gradient_color": "#130C1E", + "water": "#1A2E40", + "parks": "#0E1A10", + "road_motorway": "#FFEEA0", + "road_primary": "#F0D870", + "road_secondary": "#D8C050", + "road_tertiary": "#C0A838", + "road_residential": "#A89028", + "road_default": "#B09830", + "road_width_scale": 1.5 +} diff --git a/themes/mountain_city.json b/themes/mountain_city.json new file mode 100644 index 00000000..a93bc669 --- /dev/null +++ b/themes/mountain_city.json @@ -0,0 +1,16 @@ +{ + "name": "Mountain City", + "description": "Chongqing misty mountain city — deep slate fog night, neon orange-red streets glowing through rain haze, Jialing River deep blue, hillside forest dark green", + "bg": "#141820", + "text": "#F0DFC0", + "gradient_color": "#141820", + "water": "#1A2E48", + "parks": "#101C12", + "road_motorway": "#FF7830", + "road_primary": "#E86018", + "road_secondary": "#D05010", + "road_tertiary": "#B84010", + "road_residential": "#9C3410", + "road_default": "#A83C10", + "road_width_scale": 1.5 +} diff --git a/themes/peach_spring.json b/themes/peach_spring.json new file mode 100644 index 00000000..79abfd11 --- /dev/null +++ b/themes/peach_spring.json @@ -0,0 +1 @@ +{"_name":"Peach Spring","bg":"#0C2010","gradient_color":"#0C2010","text":"#FFF0F8","water":"#2888E0","parks":"#4A9A50","bg_patina":false,"road_motorway":"#FFFFFF","road_primary":"#EEE0EE","road_secondary":"#D4C0D4","road_tertiary":"#B8A0B8","road_residential":"#9878A0","road_default":"#7A5888","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/putuo_zen.json b/themes/putuo_zen.json new file mode 100644 index 00000000..339a28ed --- /dev/null +++ b/themes/putuo_zen.json @@ -0,0 +1,16 @@ +{ + "name": "Putuo Zen", + "description": "Buddhist island floating in Eastern Sea — ocean blue surrounds, golden temple paths glow on the island, bamboo forest green, jade bay waters", + "bg": "#2E6E92", + "text": "#F5EDD0", + "gradient_color": "#2E6E92", + "water": "#1A3F5C", + "parks": "#3D7A48", + "road_motorway": "#FFE070", + "road_primary": "#F5CF54", + "road_secondary": "#E8BC3E", + "road_tertiary": "#D8A830", + "road_residential": "#C89828", + "road_default": "#D8A830", + "road_width_scale": 2.2 +} diff --git a/themes/qinhuangdao_coast.json b/themes/qinhuangdao_coast.json new file mode 100644 index 00000000..1074ab21 --- /dev/null +++ b/themes/qinhuangdao_coast.json @@ -0,0 +1 @@ +{"_name":"Summer Beach","bg":"#1A5C8A","gradient_color":"#1A5C8A","text":"#F8F0E0","water":"#A8E0F0","parks":"#2E7A5A","bg_patina":false,"road_motorway":"#FFE8A0","road_primary":"#F0D080","road_secondary":"#D8B860","road_tertiary":"#B89040","road_residential":"#907030","road_default":"#685820","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/shenzhen_tech.json b/themes/shenzhen_tech.json new file mode 100644 index 00000000..2863bcf8 --- /dev/null +++ b/themes/shenzhen_tech.json @@ -0,0 +1,16 @@ +{ + "name": "Shenzhen Tech", + "description": "Shenzhen Bay Area innovation city — deep midnight navy night, electric cyan-teal tech grid streets, innovation corridor glow, Shenzhen Bay deep blue water", + "bg": "#0A1428", + "text": "#C8F0F8", + "gradient_color": "#0A1428", + "water": "#0C2040", + "parks": "#0A1A10", + "road_motorway": "#40F0E0", + "road_primary": "#30D8C8", + "road_secondary": "#20C0B0", + "road_tertiary": "#18A898", + "road_residential": "#108880", + "road_default": "#149088", + "road_width_scale": 1.5 +} diff --git a/themes/sichuan_spice.json b/themes/sichuan_spice.json new file mode 100644 index 00000000..648b708d --- /dev/null +++ b/themes/sichuan_spice.json @@ -0,0 +1,16 @@ +{ + "name": "Sichuan Spice", + "description": "Chengdu teahouse warmth — deep aged tea brown night, fiery Sichuan pepper roads glowing like lanterns, bamboo forest green parks, jade creek water", + "bg": "#160C06", + "text": "#F5E8C8", + "gradient_color": "#160C06", + "water": "#1C3830", + "parks": "#162A16", + "road_motorway": "#FF6B35", + "road_primary": "#E85828", + "road_secondary": "#CC4818", + "road_tertiary": "#B03810", + "road_residential": "#943008", + "road_default": "#A0340C", + "road_width_scale": 1.5 +} diff --git a/themes/spring_youth.json b/themes/spring_youth.json new file mode 100644 index 00000000..adf03f79 --- /dev/null +++ b/themes/spring_youth.json @@ -0,0 +1,15 @@ +{ + "name": "Spring Youth", + "description": "Early spring in a youthful city — cherry blossom pinks, fresh lavender roads, soft spring morning light", + "bg": "#F2EEE8", + "text": "#7A6878", + "gradient_color": "#E8DFF0", + "water": "#7AB5C8", + "parks": "#A8C89A", + "road_motorway": "#C484A0", + "road_primary": "#D4A0B8", + "road_secondary": "#B8A8D0", + "road_tertiary": "#C8C0DC", + "road_residential": "#DDD4E4", + "road_default": "#CCBFD8" +} diff --git a/themes/steppe_silver.json b/themes/steppe_silver.json new file mode 100644 index 00000000..02107c53 --- /dev/null +++ b/themes/steppe_silver.json @@ -0,0 +1,15 @@ +{ + "name": "Steppe Silver", + "description": "Inner Mongolian plateau at dawn — silver morning mist over vast grasslands, sage-grey roads stretching to the horizon, steel-blue steppe lakes, the feeling of wind turbines spinning in absolute silence", + "bg": "#EEEDE6", + "text": "#3A3E30", + "gradient_color": "#DDE0D4", + "water": "#6090B4", + "parks": "#7E9C60", + "road_motorway": "#566840", + "road_primary": "#6E8252", + "road_secondary": "#8A9C68", + "road_tertiary": "#9EAE7E", + "road_residential": "#B4BC98", + "road_default": "#BEC8A8" +} diff --git a/themes/steppe_sky.json b/themes/steppe_sky.json new file mode 100644 index 00000000..edb1a61e --- /dev/null +++ b/themes/steppe_sky.json @@ -0,0 +1,17 @@ +{ + "_name": "Steppe Sky", + "_description": "Inner Mongolia Hohhot — bright fresh grass-green background, strong royal-blue waterways, warm cream road network for high contrast", + "bg": "#3A8C40", + "gradient_color": "#3A8C40", + "text": "#F0FFF0", + "water": "#0058E8", + "parks": "#286830", + "bg_patina": false, + "road_motorway": "#F0E8C0", + "road_primary": "#D8D0A8", + "road_secondary": "#C0B890", + "road_tertiary": "#A8A080", + "road_residential": "#907868", + "road_default": "#887060", + "road_width_scale": 2.5 +} diff --git a/themes/taihu_ink.json b/themes/taihu_ink.json new file mode 100644 index 00000000..baee550f --- /dev/null +++ b/themes/taihu_ink.json @@ -0,0 +1,16 @@ +{ + "name": "Taihu Ink", + "description": "Wuxi Taihu lakeshore — deep navy sky, vivid deep-blue lake Taihu, rich forest green parks, jade-green road network evoking Jiangnan silk paths", + "bg": "#0A1520", + "gradient_color": "#0A1520", + "text": "#C8E8D0", + "water": "#1A5E8A", + "parks": "#1A5A2E", + "road_motorway": "#88D8A8", + "road_primary": "#68B888", + "road_secondary": "#50987A", + "road_tertiary": "#407868", + "road_residential": "#306058", + "road_default": "#386860", + "road_width_scale": 1.5 +} diff --git a/themes/tang_dynasty.json b/themes/tang_dynasty.json new file mode 100644 index 00000000..eb9bd935 --- /dev/null +++ b/themes/tang_dynasty.json @@ -0,0 +1,16 @@ +{ + "name": "Tang Dynasty", + "description": "Xi'an ancient capital — deep cinnabar city walls at night, molten gold boulevard paths, jade pool water, verdant imperial garden parks", + "bg": "#1A0E08", + "text": "#F2DFA0", + "gradient_color": "#1A0E08", + "water": "#1C3040", + "parks": "#0F2010", + "road_motorway": "#F0C040", + "road_primary": "#D8A828", + "road_secondary": "#C09018", + "road_tertiary": "#A87810", + "road_residential": "#8C6010", + "road_default": "#966C12", + "road_width_scale": 1.5 +} diff --git a/themes/tengger_sand.json b/themes/tengger_sand.json new file mode 100644 index 00000000..c4e53fe7 --- /dev/null +++ b/themes/tengger_sand.json @@ -0,0 +1 @@ +{"_name":"Tengger Desert","bg":"#3E3608","gradient_color":"#3E3608","text":"#F8E8C0","water":"#1870B0","parks":"#1A4020","bg_patina":true,"road_motorway":"#F0C840","road_primary":"#D8A828","road_secondary":"#B88018","road_tertiary":"#986810","road_residential":"#7A520C","road_default":"#5C3C08","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/tibetan_sky.json b/themes/tibetan_sky.json new file mode 100644 index 00000000..28c2d13f --- /dev/null +++ b/themes/tibetan_sky.json @@ -0,0 +1,18 @@ +{ + "name": "Tibetan Sky", + "description": "High-altitude Lhasa plateau — sky blue heaven meets pure grassland earth, crystalline cerulean roads, vivid blue rivers, a vertical split where altitude talks to soil", + "bg": "#5AAAD8", + "bg_top": "#0A5FC8", + "bg_bottom": "#1A9420", + "text": "#F0F8EE", + "gradient_color": "#2288D8", + "water": "#0460B0", + "parks": "#28AA28", + "road_motorway": "#FFFFFF", + "road_primary": "#E8F8FF", + "road_secondary": "#C8EAD8", + "road_tertiary": "#AADCC0", + "road_residential": "#90CCA8", + "road_default": "#AADCC0", + "road_width_scale": 1.8 +} diff --git a/themes/victoria_harbour.json b/themes/victoria_harbour.json new file mode 100644 index 00000000..7817b753 --- /dev/null +++ b/themes/victoria_harbour.json @@ -0,0 +1,16 @@ +{ + "name": "Victoria Harbour", + "description": "Hong Kong harbour night — deep midnight indigo sky, warm amber neon grid streets, Victoria Harbour deep blue, East-West colonial glow", + "bg": "#0C0E18", + "gradient_color": "#0C0E18", + "text": "#FFD888", + "water": "#091628", + "parks": "#0A130A", + "road_motorway": "#FFA030", + "road_primary": "#E08020", + "road_secondary": "#C06A10", + "road_tertiary": "#A05808", + "road_residential": "#804808", + "road_default": "#905010", + "road_width_scale": 1.5 +} diff --git a/themes/winter_peaks.json b/themes/winter_peaks.json new file mode 100644 index 00000000..85a5cc08 --- /dev/null +++ b/themes/winter_peaks.json @@ -0,0 +1,15 @@ +{ + "name": "Winter Peaks", + "description": "A windswept mountain city in deep winter — crisp ice blue roads, frozen lake water, pine-shadow green parks, cold pale snow sky", + "bg": "#EBF0F5", + "text": "#3C4E62", + "gradient_color": "#D4E4F0", + "water": "#5A8FAA", + "parks": "#6B8C7A", + "road_motorway": "#4A6F96", + "road_primary": "#6A8DB8", + "road_secondary": "#8AAACE", + "road_tertiary": "#A4BCDA", + "road_residential": "#BCC8E0", + "road_default": "#C8D2E4" +} diff --git a/themes/xiamen_sea.json b/themes/xiamen_sea.json new file mode 100644 index 00000000..c5387b2b --- /dev/null +++ b/themes/xiamen_sea.json @@ -0,0 +1 @@ +{"_name":"Garden on Sea","bg":"#1A5028","gradient_color":"#1A5028","text":"#F8FFF0","water":"#00B8E8","parks":"#28A850","bg_patina":false,"road_motorway":"#F8F0D8","road_primary":"#E8E0C8","road_secondary":"#D0C8A8","road_tertiary":"#B8A888","road_residential":"#907868","road_default":"#705840","road_width_scale":2.0} \ No newline at end of file diff --git a/themes/zhao_bronze.json b/themes/zhao_bronze.json new file mode 100644 index 00000000..79e74547 --- /dev/null +++ b/themes/zhao_bronze.json @@ -0,0 +1,18 @@ +{ + "name": "Zhao Bronze", + "description": "Handan Warring States capital — deep dark olive-teal bg, organic mottled dark-umber patina texture, bright oxidized bronze-green motorways, warm golden-copper primaries", + "bg": "#060806", + "gradient_color": "#060806", + "text": "#D8C870", + "water": "#0A1520", + "parks": "#081208", + "bg_patina": true, + "bg_patina_color": "#7A3E18", + "road_motorway": "#3DA882", + "road_primary": "#D49A20", + "road_secondary": "#B88018", + "road_tertiary": "#A07218", + "road_residential": "#886018", + "road_default": "#906818", + "road_width_scale": 1.6 +}