Spaces:
Running
Running
| import osmnx as ox | |
| import matplotlib.pyplot as plt | |
| from matplotlib.font_manager import FontProperties | |
| import matplotlib.colors as mcolors | |
| from matplotlib.patches import Rectangle | |
| import numpy as np | |
| from geopy.geocoders import Nominatim | |
| from tqdm import tqdm | |
| import time | |
| import json | |
| import os | |
| import random | |
| from datetime import datetime | |
| import argparse | |
| from shapely.geometry import Point, box | |
| from shapely.ops import linemerge, unary_union, polygonize | |
| import geopandas as gpd | |
| THEMES_DIR = "themes" | |
| FONTS_DIR = "fonts" | |
| POSTERS_DIR = "posters" | |
| # ---- Layout tuning defaults (can be overridden by CLI flags) ---- | |
| DEFAULT_DPI = 300 | |
| MAP_PAD = 0.03 | |
| OVERFETCH = 1.20 | |
| # Buildings are only practical at small scale | |
| BUILDINGS_MAX_DIST = 5000 # Town threshold | |
| import re | |
| from shapely.geometry import LineString, MultiLineString | |
| def first_tag_value(v): | |
| """OSM tags can be scalars or lists. Always return a single scalar.""" | |
| if isinstance(v, list): | |
| return v[0] if v else None | |
| return v | |
| def _safe_float(x): | |
| try: | |
| if x is None: | |
| return None | |
| if isinstance(x, (int, float)): | |
| return float(x) | |
| # Handle strings like "3", "3.5", "3 m", "3,5" | |
| s = str(x).strip().lower().replace(",", ".") | |
| m = re.search(r"[-+]?\d*\.?\d+", s) | |
| return float(m.group(0)) if m else None | |
| except Exception: | |
| return None | |
| def get_waterway_widths_by_type(waterways_gdf, theme=None): | |
| """ | |
| Return a list of linewidths (floats) aligned with waterways_gdf rows. | |
| If a numeric 'width' tag exists in OSM data, it is used as a hint. | |
| Otherwise uses a simple hierarchy by waterway type. | |
| Notes: | |
| - OSM 'width' is in meters (often missing / inconsistent). We map it to a | |
| plotting linewidth with a gentle clamp. | |
| - 'riverbank' is a polygon, so it won't normally be in waterways_gdf (lines). | |
| """ | |
| if waterways_gdf is None or getattr(waterways_gdf, "empty", True): | |
| return [] | |
| # Base widths in "plot linewidth units" | |
| base = { | |
| "river": 1.00, | |
| "canal": 0.85, | |
| "stream": 0.55, | |
| "brook": 0.45, | |
| "tidal_channel": 0.65, | |
| "ditch": 0.40, | |
| "drain": 0.38, | |
| } | |
| default_w = 0.45 | |
| # Allow theme overrides if you want later | |
| # e.g. theme["waterway_river_width"] etc. | |
| theme = theme or {} | |
| default_w = float(theme.get("waterway_default_width", default_w)) | |
| base["river"] = float(theme.get("waterway_river_width", base["river"])) | |
| base["canal"] = float(theme.get("waterway_canal_width", base["canal"])) | |
| base["stream"] = float(theme.get("waterway_stream_width", base["stream"])) | |
| base["brook"] = float(theme.get("waterway_brook_width", base["brook"])) | |
| base["ditch"] = float(theme.get("waterway_ditch_width", base["ditch"])) | |
| base["drain"] = float(theme.get("waterway_drain_width", base["drain"])) | |
| base["tidal_channel"] = float(theme.get("waterway_tidal_width", base["tidal_channel"])) | |
| widths = [] | |
| # Pre-check columns | |
| has_type = "waterway" in waterways_gdf.columns | |
| has_width = "width" in waterways_gdf.columns | |
| for _, row in waterways_gdf.iterrows(): | |
| wtype = first_tag_value(row.get("waterway")) if has_type else None | |
| lw = base.get(wtype, default_w) | |
| # Optional: use OSM "width" tag as a hint when present | |
| if has_width: | |
| osm_w = _safe_float(first_tag_value(row.get("width"))) | |
| if osm_w is not None: | |
| # Map meters -> linewidth. Keep it subtle. | |
| # 0..30m -> 0.4..2.2 (clamped) | |
| mapped = 0.4 + (min(max(osm_w, 0.0), 30.0) / 30.0) * 1.8 | |
| # Blend with hierarchy so width doesn't explode on bad tags | |
| lw = 0.6 * lw + 0.4 * mapped | |
| # Safety clamp (avoids ugly giant strokes) | |
| lw = float(np.clip(lw, 0.25, 2.4)) | |
| widths.append(lw) | |
| return widths | |
| def get_waterway_alphas_by_type(waterways_gdf, theme=None): | |
| """ | |
| Optional: alpha hierarchy (bigger waterways slightly more opaque). | |
| """ | |
| if waterways_gdf is None or getattr(waterways_gdf, "empty", True): | |
| return [] | |
| theme = theme or {} | |
| base = { | |
| "river": 0.75, | |
| "canal": 0.70, | |
| "stream": 0.60, | |
| "brook": 0.55, | |
| "tidal_channel": 0.65, | |
| "ditch": 0.50, | |
| "drain": 0.50, | |
| } | |
| default_a = float(theme.get("waterway_default_alpha", 0.60)) | |
| alphas = [] | |
| has_type = "waterway" in waterways_gdf.columns | |
| for _, row in waterways_gdf.iterrows(): | |
| wtype = first_tag_value(row.get("waterway")) if has_type else None | |
| a = float(base.get(wtype, default_a)) | |
| a = float(np.clip(a, 0.25, 0.90)) | |
| alphas.append(a) | |
| return alphas | |
| def plot_waterways_by_hierarchy( | |
| ax, | |
| waterways_gdf, | |
| *, | |
| color, | |
| theme=None, | |
| zorder=2.2, | |
| dash=None, | |
| ): | |
| """ | |
| Plot waterways with per-feature linewidth/alpha (like road hierarchy). | |
| - dash=None => solid line | |
| - dash=(0,(3,3)) => dashed etc. | |
| """ | |
| if waterways_gdf is None or getattr(waterways_gdf, "empty", True): | |
| return | |
| theme = theme or {} | |
| widths = get_waterway_widths_by_type(waterways_gdf, theme=theme) | |
| alphas = get_waterway_alphas_by_type(waterways_gdf, theme=theme) | |
| # Iterate geometries and plot with their style | |
| i = 0 | |
| for geom in waterways_gdf.geometry: | |
| if geom is None or geom.is_empty: | |
| i += 1 | |
| continue | |
| lw = widths[i] if i < len(widths) else 0.45 | |
| alpha = alphas[i] if i < len(alphas) else 0.60 | |
| i += 1 | |
| if isinstance(geom, LineString): | |
| xs, ys = geom.xy | |
| ax.plot( | |
| xs, ys, | |
| color=color, | |
| linewidth=lw, | |
| alpha=alpha, | |
| zorder=zorder, | |
| linestyle=dash if dash is not None else "solid", | |
| ) | |
| elif isinstance(geom, MultiLineString): | |
| for part in geom.geoms: | |
| xs, ys = part.xy | |
| ax.plot( | |
| xs, ys, | |
| color=color, | |
| linewidth=lw, | |
| alpha=alpha, | |
| zorder=zorder, | |
| linestyle=dash if dash is not None else "solid", | |
| ) | |
| def finalize_map_view(ax_map, target_ratio: float, *, pad: float): | |
| """ | |
| Vue finale sans distorsion et sans letterboxing (bandes blanches). | |
| """ | |
| # 1) Empêcher les marges auto (GeoPandas en ajoute parfois) | |
| ax_map.margins(0) | |
| ax_map.set_anchor("C") | |
| # 2) IMPORTANT : on veut que l'ASPECT ajuste les LIMITES (pas la boîte) | |
| ax_map.set_aspect("equal", adjustable="datalim") | |
| # 3) Crop au ratio cible (pad optionnel) | |
| crop_axes_to_ratio(ax_map, target_ratio=target_ratio, pad=pad) | |
| # 4) IMPORTANT : l'axes doit remplir la figure (sinon bandes blanches) | |
| ax_map.set_position([0, 0, 1, 1]) | |
| def get_figsize(map_format: str, orientation: str): | |
| """ | |
| Return (width, height) in inches for a given format + orientation. | |
| 'plan' is always square (1:1) and ignores orientation. | |
| """ | |
| map_format = (map_format or "poster").lower() | |
| orientation = (orientation or "portrait").lower() | |
| # Plan = square (orientation ignored) | |
| if map_format == "plan": | |
| return (12, 12) | |
| # Portrait vs Landscape mappings | |
| if map_format == "poster": | |
| return (12, 16) if orientation == "portrait" else (16, 12) # 3:4 / 4:3 | |
| if map_format == "postcard": | |
| return (12, 15) if orientation == "portrait" else (15, 12) # 4:5 / 5:4 | |
| if map_format == "panoramic": | |
| return (9, 16) if orientation == "portrait" else (16, 9) # 9:16 / 16:9 | |
| # Fallback | |
| return (12, 16) | |
| def load_fonts(): | |
| """ | |
| Load Roboto fonts from the fonts directory. | |
| Returns dict with font paths for different weights. | |
| """ | |
| fonts = { | |
| 'bold': os.path.join(FONTS_DIR, 'Roboto-Bold.ttf'), | |
| 'regular': os.path.join(FONTS_DIR, 'Roboto-Regular.ttf'), | |
| 'light': os.path.join(FONTS_DIR, 'Roboto-Light.ttf') | |
| } | |
| # Verify fonts exist | |
| for weight, path in fonts.items(): | |
| if not os.path.exists(path): | |
| print(f"⚠ Font not found: {path}") | |
| return None | |
| return fonts | |
| FONTS = load_fonts() | |
| def generate_output_filename(city, theme_name): | |
| """ | |
| Generate unique output filename with city, theme, and datetime. | |
| """ | |
| if not os.path.exists(POSTERS_DIR): | |
| os.makedirs(POSTERS_DIR) | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| city_slug = (city or "custom").lower().replace(' ', '_') | |
| filename = f"{city_slug}_{theme_name}_{timestamp}.png" | |
| return os.path.join(POSTERS_DIR, filename) | |
| def get_available_themes(): | |
| """ | |
| Scans the themes directory and returns a list of available theme names. | |
| """ | |
| if not os.path.exists(THEMES_DIR): | |
| os.makedirs(THEMES_DIR) | |
| return [] | |
| themes = [] | |
| for file in sorted(os.listdir(THEMES_DIR)): | |
| if file.endswith('.json'): | |
| theme_name = file[:-5] # Remove .json extension | |
| themes.append(theme_name) | |
| return themes | |
| def load_theme(theme_name="feature_based"): | |
| """ | |
| Load theme from JSON file in themes directory. | |
| """ | |
| theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json") | |
| if not os.path.exists(theme_file): | |
| print(f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme.") | |
| # Fallback to embedded default theme | |
| return { | |
| "name": "Feature-Based Shading", | |
| "bg": "#FFFFFF", | |
| "text": "#000000", | |
| "gradient_color": "#FFFFFF", | |
| "water": "#C0C0C0", | |
| "parks": "#F0F0F0", | |
| "road_motorway": "#0A0A0A", | |
| "road_primary": "#1A1A1A", | |
| "road_secondary": "#2A2A2A", | |
| "road_tertiary": "#3A3A3A", | |
| "road_residential": "#4A4A4A", | |
| "road_default": "#3A3A3A", | |
| # buildings fallback | |
| "buildings": "#E6E6E6", | |
| "buildings_alpha": 0.25, | |
| } | |
| with open(theme_file, 'r') as f: | |
| theme = json.load(f) | |
| print(f"✓ Loaded theme: {theme.get('name', theme_name)}") | |
| if 'description' in theme: | |
| print(f" {theme['description']}") | |
| return theme | |
| # Loaded later | |
| THEME = None | |
| def crop_axes_to_ratio(ax, target_ratio, pad=0.03): | |
| """ | |
| Crop ax limits to match target_ratio (width/height) with a consistent padding feel. | |
| pad shrinks the final view (same spirit as before): pad=0.03 => ~3% tighter. | |
| """ | |
| x0, x1 = ax.get_xlim() | |
| y0, y1 = ax.get_ylim() | |
| w = x1 - x0 | |
| h = y1 - y0 | |
| if w <= 0 or h <= 0: | |
| return | |
| cx = (x0 + x1) / 2 | |
| cy = (y0 + y1) / 2 | |
| current_ratio = w / h | |
| # Fit to ratio using the *current* limits as the starting envelope | |
| if current_ratio > target_ratio: | |
| # too wide -> reduce width | |
| half_h = h / 2 | |
| half_w = half_h * target_ratio | |
| else: | |
| # too tall -> reduce height | |
| half_w = w / 2 | |
| half_h = half_w / target_ratio | |
| # Shrink uniformly (keeps margins visually consistent in the "inland+fade" case) | |
| pad = float(max(0.0, min(0.2, pad))) | |
| shrink = 1.0 - pad | |
| half_w *= shrink | |
| half_h *= shrink | |
| ax.set_xlim(cx - half_w, cx + half_w) | |
| ax.set_ylim(cy - half_h, cy + half_h) | |
| def create_gradient_fade(ax, color, location='bottom', zorder=10, thickness=0.25): | |
| """ | |
| Creates a fade effect at the edges of the map without altering aspect/limits. | |
| location: 'bottom', 'top', 'left', 'right' | |
| thickness: fraction of the axis range to cover (e.g. 0.25 = 25%) | |
| """ | |
| thickness = float(max(0.02, min(0.40, thickness))) # clamp safe | |
| vals = np.linspace(0, 1, 256).reshape(-1, 1) | |
| 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] | |
| # Save current view/aspect so imshow can't distort the map | |
| xlim = ax.get_xlim() | |
| ylim = ax.get_ylim() | |
| aspect = ax.get_aspect() | |
| x_range = xlim[1] - xlim[0] | |
| y_range = ylim[1] - ylim[0] | |
| if location == 'bottom': | |
| gradient = np.hstack((vals, vals)) # vertical | |
| my_colors[:, 3] = np.linspace(1, 0, 256) | |
| y0 = ylim[0] | |
| y1 = ylim[0] + y_range * thickness | |
| extent = [xlim[0], xlim[1], y0, y1] | |
| elif location == 'top': | |
| gradient = np.hstack((vals, vals)) # vertical | |
| my_colors[:, 3] = np.linspace(0, 1, 256) | |
| y0 = ylim[1] - y_range * thickness | |
| y1 = ylim[1] | |
| extent = [xlim[0], xlim[1], y0, y1] | |
| elif location == 'left': | |
| gradient = np.vstack((vals.T, vals.T)) # horizontal | |
| my_colors[:, 3] = np.linspace(1, 0, 256) | |
| x0 = xlim[0] | |
| x1 = xlim[0] + x_range * thickness | |
| extent = [x0, x1, ylim[0], ylim[1]] | |
| elif location == 'right': | |
| gradient = np.vstack((vals.T, vals.T)) # horizontal | |
| my_colors[:, 3] = np.linspace(0, 1, 256) | |
| x0 = xlim[1] - x_range * thickness | |
| x1 = xlim[1] | |
| extent = [x0, x1, ylim[0], ylim[1]] | |
| else: | |
| return | |
| custom_cmap = mcolors.ListedColormap(my_colors) | |
| ax.imshow( | |
| gradient, | |
| extent=extent, | |
| aspect='auto', | |
| cmap=custom_cmap, | |
| zorder=zorder, | |
| origin='lower' | |
| ) | |
| # Restore view/aspect | |
| ax.set_xlim(xlim) | |
| ax.set_ylim(ylim) | |
| ax.set_aspect(aspect) | |
| def margin_thickness_from_layout(theme: dict) -> float: | |
| """ | |
| Margin thickness in axes fraction for the "solid frame" used on coastal maps | |
| (when fading is disabled). Same thickness on all 4 sides, orientation-invariant. | |
| """ | |
| fmt = (theme.get("_format") or "poster").lower() | |
| if fmt == "plan": | |
| return 0.035 | |
| if fmt == "panoramic": | |
| return 0.025 | |
| if fmt == "postcard": | |
| return 0.03 | |
| # poster default | |
| return 0.03 | |
| def add_margin_frame(ax_page, color, thickness=0.03, zorder=10): | |
| """ | |
| Draw a solid margin/frame over the map with uniform thickness on all 4 sides. | |
| thickness is in axes fraction (0..1). | |
| """ | |
| t = float(max(0.0, min(0.15, thickness))) | |
| if t <= 0: | |
| return | |
| # left | |
| ax_page.add_patch(Rectangle( | |
| (0, 0), t, 1, | |
| transform=ax_page.transAxes, | |
| facecolor=color, edgecolor="none", | |
| zorder=zorder | |
| )) | |
| # right | |
| ax_page.add_patch(Rectangle( | |
| (1 - t, 0), t, 1, | |
| transform=ax_page.transAxes, | |
| facecolor=color, edgecolor="none", | |
| zorder=zorder | |
| )) | |
| # bottom | |
| ax_page.add_patch(Rectangle( | |
| (0, 0), 1, t, | |
| transform=ax_page.transAxes, | |
| facecolor=color, edgecolor="none", | |
| zorder=zorder | |
| )) | |
| # top | |
| ax_page.add_patch(Rectangle( | |
| (0, 1 - t), 1, t, | |
| transform=ax_page.transAxes, | |
| facecolor=color, edgecolor="none", | |
| zorder=zorder | |
| )) | |
| def buildings_style_from_text(theme: dict): | |
| """ | |
| Buildings default to the theme text color (solid). | |
| Alpha is only applied if explicitly provided in the theme JSON. | |
| """ | |
| text = theme.get("text", "#000000") | |
| # Default: same color as text, fully opaque | |
| face = text | |
| alpha = 1.0 | |
| # Optional explicit overrides from theme JSON | |
| if "buildings" in theme: | |
| face = theme["buildings"] | |
| if "buildings_alpha" in theme: | |
| alpha = float(theme["buildings_alpha"]) | |
| alpha = max(0.05, min(1.0, alpha)) | |
| return face, alpha | |
| def get_edge_colors_by_type(G): | |
| """ | |
| Assign colors to edges based on road type hierarchy. | |
| """ | |
| edge_colors = [] | |
| for _, _, data in G.edges(data=True): | |
| highway = data.get('highway', 'unclassified') | |
| if isinstance(highway, list): | |
| highway = highway[0] if highway else 'unclassified' | |
| if highway in ['motorway', 'motorway_link']: | |
| color = THEME['road_motorway'] | |
| elif highway in ['trunk', 'trunk_link', 'primary', 'primary_link']: | |
| color = THEME['road_primary'] | |
| elif highway in ['secondary', 'secondary_link']: | |
| color = THEME['road_secondary'] | |
| elif highway in ['tertiary', 'tertiary_link']: | |
| color = THEME['road_tertiary'] | |
| elif highway in ['residential', 'living_street', 'unclassified']: | |
| color = THEME['road_residential'] | |
| else: | |
| color = THEME['road_default'] | |
| edge_colors.append(color) | |
| return edge_colors | |
| def get_edge_widths_by_type(G): | |
| """ | |
| Assign line widths to edges based on road type. | |
| """ | |
| edge_widths = [] | |
| for _, _, data in G.edges(data=True): | |
| highway = data.get('highway', 'unclassified') | |
| if isinstance(highway, list): | |
| highway = highway[0] if highway else 'unclassified' | |
| if highway in ['motorway', 'motorway_link']: | |
| width = 1.2 | |
| elif highway in ['trunk', 'trunk_link', 'primary', 'primary_link']: | |
| width = 1.0 | |
| elif highway in ['secondary', 'secondary_link']: | |
| width = 0.8 | |
| elif highway in ['tertiary', 'tertiary_link']: | |
| width = 0.6 | |
| else: | |
| width = 0.4 | |
| edge_widths.append(width) | |
| return edge_widths | |
| def safe_city_width_from_layout(theme: dict) -> float: | |
| """ | |
| Return safe horizontal width (axes fraction) for the CITY label, | |
| depending on output format/orientation. | |
| """ | |
| fmt = (theme.get("_format") or "poster").lower() | |
| ori = (theme.get("_orientation") or "portrait").lower() | |
| if fmt == "plan": | |
| return 0.84 | |
| if fmt == "panoramic": | |
| return 0.92 if ori == "landscape" else 0.82 | |
| if fmt == "postcard": | |
| return 0.88 if ori == "landscape" else 0.84 | |
| return 0.90 if ori == "landscape" else 0.86 | |
| def layout_bottom_text(ax_page, city_text, country_text, coords_text, theme, fonts, bottom_offset=0.0, zorder=11): | |
| """ | |
| Place city/country/coords + separator line using measured text extents. | |
| Smart fit for long city names: | |
| 1) reduce tracking (spaces between letters) | |
| 2) shrink font size (smart scaling jump, then fine steps) | |
| Preserves word boundaries: smaller spacing between letters than between words. | |
| """ | |
| if fonts: | |
| font_main_size = 60 | |
| font_main = FontProperties(fname=fonts['bold'], size=font_main_size) | |
| font_sub = FontProperties(fname=fonts['light'], size=22) | |
| font_coords = FontProperties(fname=fonts['regular'], size=14) | |
| font_attr = FontProperties(fname=fonts['light'], size=8) | |
| else: | |
| font_main_size = 60 | |
| font_main = FontProperties(family='monospace', weight='bold', size=font_main_size) | |
| font_sub = FontProperties(family='monospace', weight='normal', size=22) | |
| font_coords = FontProperties(family='monospace', size=14) | |
| font_attr = FontProperties(family='monospace', size=8) | |
| fig = ax_page.figure | |
| raw_city = " ".join((city_text or "").split()) | |
| if not raw_city: | |
| raw_city = "CUSTOM" | |
| def make_tracked(spaces_between_letters: int) -> str: | |
| spaces_between_letters = int(max(1, min(6, spaces_between_letters))) | |
| letter_sep = " " * spaces_between_letters | |
| # subtle larger gap between words | |
| word_spaces = spaces_between_letters + 2 | |
| word_sep = " " * word_spaces | |
| words = raw_city.split(" ") | |
| tracked_words = [letter_sep.join(list(w)) for w in words if w] | |
| return word_sep.join(tracked_words) | |
| t_city = ax_page.text( | |
| 0.5, 0.5, make_tracked(2), | |
| transform=ax_page.transAxes, color=theme['text'], | |
| ha='center', va='bottom', fontproperties=font_main, zorder=zorder | |
| ) | |
| t_country = ax_page.text( | |
| 0.5, 0.5, country_text, | |
| transform=ax_page.transAxes, color=theme['text'], | |
| ha='center', va='bottom', fontproperties=font_sub, zorder=zorder | |
| ) | |
| t_coords = ax_page.text( | |
| 0.5, 0.5, coords_text, | |
| transform=ax_page.transAxes, color=theme['text'], alpha=0.7, | |
| ha='center', va='bottom', fontproperties=font_coords, zorder=zorder | |
| ) | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| def width_in_axes(text_obj): | |
| bbox = text_obj.get_window_extent(renderer=renderer) | |
| inv = ax_page.transAxes.inverted() | |
| x0 = inv.transform((bbox.x0, 0))[0] | |
| x1 = inv.transform((bbox.x1, 0))[0] | |
| return max(0.0, x1 - x0) | |
| def height_in_axes(text_obj): | |
| bbox = text_obj.get_window_extent(renderer=renderer) | |
| inv = ax_page.transAxes.inverted() | |
| y0 = inv.transform((0, bbox.y0))[1] | |
| y1 = inv.transform((0, bbox.y1))[1] | |
| return max(0.0, y1 - y0) | |
| safe_width = safe_city_width_from_layout(theme) | |
| fmt = (theme.get("_format") or "poster").lower() | |
| ori = (theme.get("_orientation") or "portrait").lower() | |
| if fmt == "panoramic" and ori == "portrait": | |
| min_city_size = 18 | |
| elif fmt == "plan": | |
| min_city_size = 20 | |
| else: | |
| min_city_size = 22 | |
| max_iters = 30 | |
| current_spaces = 2 | |
| w_city = width_in_axes(t_city) | |
| while w_city > safe_width and current_spaces > 1: | |
| current_spaces -= 1 | |
| t_city.set_text(make_tracked(current_spaces)) | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| w_city = width_in_axes(t_city) | |
| if w_city > safe_width: | |
| scale = safe_width / max(w_city, 1e-6) | |
| font_main_size = max(min_city_size, int(font_main_size * scale)) | |
| if fonts: | |
| font_main = FontProperties(fname=fonts['bold'], size=font_main_size) | |
| else: | |
| font_main = FontProperties(family='monospace', weight='bold', size=font_main_size) | |
| t_city.set_fontproperties(font_main) | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| w_city = width_in_axes(t_city) | |
| for _ in range(max_iters): | |
| if w_city <= safe_width or font_main_size <= min_city_size: | |
| break | |
| font_main_size -= 2 | |
| if fonts: | |
| font_main = FontProperties(fname=fonts['bold'], size=font_main_size) | |
| else: | |
| font_main = FontProperties(family='monospace', weight='bold', size=font_main_size) | |
| t_city.set_fontproperties(font_main) | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| w_city = width_in_axes(t_city) | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| h_city = height_in_axes(t_city) | |
| h_country = height_in_axes(t_country) | |
| h_coords = height_in_axes(t_coords) | |
| bottom_offset = float(max(0.0, min(0.20, bottom_offset))) | |
| bottom_margin = 0.04 + bottom_offset | |
| gap_small = 0.012 | |
| gap_line = 0.012 | |
| y_coords = bottom_margin | |
| y_country = y_coords + h_coords + gap_small | |
| y_line = y_country + h_country + gap_line | |
| y_city = y_line + gap_line | |
| t_coords.set_position((0.5, y_coords)) | |
| t_country.set_position((0.5, y_country)) | |
| t_city.set_position((0.5, y_city)) | |
| ax_page.plot( | |
| [0.4, 0.6], [y_line, y_line], | |
| transform=ax_page.transAxes, color=theme['text'], | |
| linewidth=1, zorder=zorder | |
| ) | |
| ax_page.text( | |
| 0.98, 0.02 + bottom_offset, "© OpenStreetMap contributors", | |
| transform=ax_page.transAxes, color=theme['text'], alpha=0.5, | |
| ha='right', va='bottom', fontproperties=font_attr, zorder=zorder | |
| ) | |
| def add_bottom_text_fade(ax_page, color, y0=0.0, height=0.22, alpha_max=0.85, zorder=10): | |
| """ | |
| Add a vertical fade from solid `color` at the very bottom to transparent upward. | |
| Drawn in axes coordinates on ax_page (0..1). | |
| """ | |
| height = float(max(0.02, min(0.60, height))) | |
| alpha_max = float(max(0.0, min(1.0, alpha_max))) | |
| vals = np.linspace(0, 1, 256).reshape(-1, 1) # 0..1 bottom->top | |
| rgb = mcolors.to_rgb(color) | |
| rgba = np.zeros((256, 1, 4)) | |
| rgba[..., 0] = rgb[0] | |
| rgba[..., 1] = rgb[1] | |
| rgba[..., 2] = rgb[2] | |
| # bottom: alpha_max, top: 0 | |
| rgba[..., 3] = (1 - vals) * alpha_max | |
| extent = [0, 1, y0, y0 + height] | |
| ax_page.imshow( | |
| rgba, | |
| extent=extent, | |
| transform=ax_page.transAxes, | |
| origin="lower", | |
| aspect="auto", | |
| zorder=zorder, | |
| interpolation="bicubic", | |
| ) | |
| def ensure_ssl_certs(): | |
| """ | |
| Help requests/geopy/osmnx find a valid CA bundle (common macOS python.org issue). | |
| Does NOT disable SSL verification. | |
| """ | |
| try: | |
| import certifi | |
| ca_path = certifi.where() | |
| # Only set if user hasn't explicitly set them | |
| os.environ.setdefault("SSL_CERT_FILE", ca_path) | |
| os.environ.setdefault("REQUESTS_CA_BUNDLE", ca_path) | |
| except Exception: | |
| # If certifi isn't installed, do nothing (user must fix system certs) | |
| pass | |
| GEOCODE_CACHE_FILE = ".geocode_cache.json" | |
| def _load_geocode_cache(): | |
| try: | |
| if os.path.exists(GEOCODE_CACHE_FILE): | |
| with open(GEOCODE_CACHE_FILE, "r") as f: | |
| return json.load(f) | |
| except Exception: | |
| pass | |
| return {} | |
| def _save_geocode_cache(cache: dict): | |
| try: | |
| with open(GEOCODE_CACHE_FILE, "w") as f: | |
| json.dump(cache, f) | |
| except Exception: | |
| pass | |
| def get_coordinates(city, country, max_retries=5): | |
| """ | |
| Fetch coordinates for a given city and country using geopy (Nominatim), | |
| with retry/backoff to handle throttling (429/503) and transient network issues. | |
| Also uses a small local cache to avoid repeated queries. | |
| """ | |
| ensure_ssl_certs() # safe no-op if not needed | |
| city = (city or "").strip() | |
| country = (country or "").strip() | |
| if not city or not country: | |
| raise ValueError("City and country are required for geocoding.") | |
| key = f"{city.lower()}|{country.lower()}" | |
| cache = _load_geocode_cache() | |
| if key in cache: | |
| lat, lon = cache[key] | |
| print(f"✓ Cache hit for {city}, {country}: {lat}, {lon}") | |
| return (lat, lon) | |
| print("Looking up coordinates...") | |
| geolocator = Nominatim(user_agent="maptoposter/1.0 (contact: none)") # keep a stable UA | |
| query = f"{city}, {country}" | |
| # Nominatim etiquette: don't hammer it | |
| base_sleep = 1.0 | |
| last_err = None | |
| for attempt in range(1, max_retries + 1): | |
| try: | |
| # small spacing even on first attempt | |
| if attempt == 1: | |
| time.sleep(base_sleep) | |
| location = geolocator.geocode(query, timeout=10) | |
| if location: | |
| lat, lon = float(location.latitude), float(location.longitude) | |
| print(f"✓ Found: {location.address}") | |
| print(f"✓ Coordinates: {lat}, {lon}") | |
| cache[key] = [lat, lon] | |
| _save_geocode_cache(cache) | |
| return (lat, lon) | |
| # If geocode returns None, don't spam retries too aggressively | |
| last_err = ValueError(f"Could not find coordinates for {city}, {country}") | |
| except Exception as e: | |
| last_err = e | |
| # Backoff with jitter (handles 429/503/timeouts etc.) | |
| # 1s, 2s, 4s, 8s... capped a bit + jitter | |
| backoff = min(30.0, (2 ** (attempt - 1)) * base_sleep) | |
| jitter = random.uniform(0.0, 0.5 * backoff) | |
| wait = backoff + jitter | |
| print(f"⚠ Geocoding failed (attempt {attempt}/{max_retries}): {last_err}") | |
| if attempt < max_retries: | |
| print(f" Retrying in {wait:.1f}s...") | |
| time.sleep(wait) | |
| raise last_err if last_err else ValueError(f"Could not find coordinates for {city}, {country}") | |
| def _clean_polygons(gdf): | |
| """Keep only Polygon/MultiPolygon geometries (drop points/lines).""" | |
| if gdf is None or getattr(gdf, "empty", True): | |
| return None | |
| try: | |
| gdf = gdf[gdf.geometry.notnull()].copy() | |
| gdf = gdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])].copy() | |
| return gdf | |
| except Exception: | |
| return gdf | |
| def _clean_lines(gdf): | |
| """Keep only LineString/MultiLineString geometries (drop points/polygons).""" | |
| if gdf is None or getattr(gdf, "empty", True): | |
| return None | |
| try: | |
| gdf = gdf[gdf.geometry.notnull()].copy() | |
| gdf = gdf[gdf.geom_type.isin(["LineString", "MultiLineString"])].copy() | |
| return gdf | |
| except Exception: | |
| return gdf | |
| def ensure_graph_xy(G): | |
| """ | |
| Ensure every node has 'x'/'y'. Some OSMnx/networkx graphs can miss them | |
| (or only have geometry/lat/lon). This prevents KeyError in ox.plot_graph(). | |
| """ | |
| fixed = 0 | |
| for n, data in G.nodes(data=True): | |
| if "x" in data and "y" in data: | |
| continue | |
| # fallback: lat/lon naming | |
| if "lon" in data and "lat" in data: | |
| data["x"] = float(data["lon"]) | |
| data["y"] = float(data["lat"]) | |
| fixed += 1 | |
| continue | |
| # fallback: geometry point | |
| geom = data.get("geometry") | |
| if geom is not None and hasattr(geom, "x") and hasattr(geom, "y"): | |
| data["x"] = float(geom.x) | |
| data["y"] = float(geom.y) | |
| fixed += 1 | |
| if fixed: | |
| print(f"⚠ Fixed {fixed} nodes missing x/y (to satisfy ox.plot_graph).") | |
| return G | |
| def drop_nodes_missing_xy(G, label="graph"): | |
| bad = [n for n, d in G.nodes(data=True) if ("x" not in d) or ("y" not in d)] | |
| if bad: | |
| print(f"⚠ {label}: dropping {len(bad)} nodes missing x/y (pre-plot safety)") | |
| G.remove_nodes_from(bad) | |
| return G | |
| def plot_lines_dashed(ax, gdf, color, lw, alpha, zorder, dash=(0, (3, 3))): | |
| if gdf is None or getattr(gdf, "empty", True): | |
| return | |
| for geom in gdf.geometry: | |
| if geom is None or geom.is_empty: | |
| continue | |
| if isinstance(geom, LineString): | |
| xs, ys = geom.xy | |
| ax.plot(xs, ys, color=color, linewidth=lw, alpha=alpha, zorder=zorder, linestyle=dash) | |
| elif isinstance(geom, MultiLineString): | |
| for part in geom.geoms: | |
| xs, ys = part.xy | |
| ax.plot(xs, ys, color=color, linewidth=lw, alpha=alpha, zorder=zorder, linestyle=dash) | |
| def coastline_land_polygon(coast_gdf, center_latlon, crs, bbox_poly): | |
| """ | |
| Build a land polygon inside bbox using coastline lines. | |
| Works even when coastline is not closed: we add bbox boundary and polygonize. | |
| Returns a shapely Polygon/MultiPolygon in projected CRS, or None. | |
| """ | |
| if coast_gdf is None or getattr(coast_gdf, "empty", True): | |
| return None | |
| coast_gdf = _clean_lines(coast_gdf) | |
| if coast_gdf is None or getattr(coast_gdf, "empty", True): | |
| return None | |
| try: | |
| if hasattr(coast_gdf, "to_crs") and crs is not None and not coast_gdf.empty: | |
| coast_gdf = coast_gdf.to_crs(crs) | |
| except Exception: | |
| pass | |
| # Center point in projected CRS | |
| lat, lon = center_latlon | |
| try: | |
| center_pt = gpd.GeoSeries([Point(lon, lat)], crs="EPSG:4326").to_crs(crs).iloc[0] | |
| except Exception: | |
| return None | |
| try: | |
| coast_union = unary_union(list(coast_gdf.geometry)) | |
| coast_merged = linemerge(coast_union) | |
| # Add bbox boundary so polygonize can succeed even with open coastlines | |
| merged = unary_union([coast_merged, bbox_poly.boundary]) | |
| polys = list(polygonize(merged)) | |
| if not polys: | |
| return None | |
| except Exception: | |
| return None | |
| # Pick polygon containing center (usually land) | |
| containing = [p for p in polys if p.contains(center_pt)] | |
| if containing: | |
| containing.sort(key=lambda p: p.area, reverse=True) | |
| return containing[0] | |
| polys.sort(key=lambda p: p.area, reverse=True) | |
| return polys[0] | |
| def create_poster( | |
| city, | |
| country, | |
| point, | |
| dist, | |
| output_file, | |
| enable_buildings=False, | |
| enable_railroads=False, | |
| enable_margins=True, | |
| enable_waterways=True, | |
| enable_forests=True, | |
| enable_coastline=True, | |
| ): | |
| print(f"\nGenerating map for {city}, {country}...") | |
| dist_fetch = int(dist * OVERFETCH) | |
| if enable_buildings and int(dist) > int(BUILDINGS_MAX_DIST): | |
| print(f"⚠ Buildings layer disabled (distance {dist}m > {BUILDINGS_MAX_DIST}m).") | |
| enable_buildings = False | |
| # ---- Build dynamic progress steps (avoid downloading unused layers) ---- | |
| # Always: streets + water polygons + green (parks/grass) | |
| steps = 3 | |
| if enable_coastline: | |
| steps += 1 | |
| if enable_waterways: | |
| steps += 1 | |
| if enable_forests: | |
| steps += 1 | |
| if enable_buildings: | |
| steps += 1 | |
| if enable_railroads: | |
| steps += 1 | |
| with tqdm( | |
| total=steps, | |
| desc="Fetching map data", | |
| unit="step", | |
| bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}", | |
| ) as pbar: | |
| # ---- Streets ---- | |
| pbar.set_description("Downloading street network") | |
| G = ox.graph_from_point(point, dist=dist_fetch, dist_type="bbox", network_type="all") | |
| pbar.update(1) | |
| time.sleep(0.2) | |
| # ---- Coastline (optional) ---- | |
| coast = None | |
| if enable_coastline: | |
| pbar.set_description("Downloading coastline") | |
| try: | |
| coast = ox.features_from_point(point, tags={"natural": "coastline"}, dist=dist_fetch) | |
| except Exception: | |
| coast = None | |
| pbar.update(1) | |
| time.sleep(0.1) | |
| # ---- Water polygons (always) ---- | |
| pbar.set_description("Downloading water features") | |
| try: | |
| water = ox.features_from_point( | |
| point, tags={"natural": "water", "waterway": "riverbank"}, dist=dist_fetch | |
| ) | |
| except Exception: | |
| water = None | |
| pbar.update(1) | |
| time.sleep(0.15) | |
| # ---- Waterways lines (optional) ---- | |
| waterways = None | |
| if enable_waterways: | |
| pbar.set_description("Downloading waterways") | |
| try: | |
| waterways = ox.features_from_point( | |
| point, | |
| tags={"waterway": ["river", "stream", "brook", "canal", "ditch", "drain", "tidal_channel"]}, | |
| dist=dist_fetch, | |
| ) | |
| except Exception: | |
| waterways = None | |
| pbar.update(1) | |
| time.sleep(0.1) | |
| # ---- Green (parks + grass) (always) ---- | |
| green = None | |
| pbar.set_description("Downloading parks / grass") | |
| try: | |
| green = ox.features_from_point( | |
| point, | |
| tags={ | |
| "leisure": "park", | |
| "landuse": ["grass"], | |
| }, | |
| dist=dist_fetch, | |
| ) | |
| except Exception: | |
| green = None | |
| pbar.update(1) | |
| time.sleep(0.1) | |
| # ---- Forests (optional, separated) ---- | |
| forests = None | |
| if enable_forests: | |
| pbar.set_description("Downloading forests") | |
| try: | |
| forests = ox.features_from_point( | |
| point, | |
| tags={ | |
| "landuse": "forest", | |
| "natural": "wood", | |
| }, | |
| dist=dist_fetch, | |
| ) | |
| except Exception: | |
| forests = None | |
| pbar.update(1) | |
| time.sleep(0.1) | |
| # ---- Buildings (optional) ---- | |
| buildings = None | |
| if enable_buildings: | |
| pbar.set_description("Downloading buildings") | |
| try: | |
| buildings = ox.features_from_point(point, tags={"building": True}, dist=dist_fetch) | |
| except Exception: | |
| buildings = None | |
| pbar.update(1) | |
| time.sleep(0.1) | |
| # ---- Railroads (optional) ---- | |
| rails = None | |
| if enable_railroads: | |
| pbar.set_description("Downloading railroads") | |
| try: | |
| rails = ox.features_from_point(point, tags={"railway": "rail"}, dist=dist_fetch) | |
| except Exception: | |
| rails = None | |
| pbar.update(1) | |
| time.sleep(0.1) | |
| print("✓ All data downloaded successfully!") | |
| # ---- Project to metric CRS ---- | |
| G = ox.project_graph(G) | |
| G = ensure_graph_xy(G) | |
| G = drop_nodes_missing_xy(G, label="projected G") | |
| crs = G.graph.get("crs", None) | |
| # Build bbox polygon in projected CRS from graph nodes | |
| bbox_poly = None | |
| try: | |
| xs = [data["x"] for _, data in G.nodes(data=True)] | |
| ys = [data["y"] for _, data in G.nodes(data=True)] | |
| if xs and ys: | |
| bbox_poly = box(min(xs), min(ys), max(xs), max(ys)) | |
| except Exception: | |
| bbox_poly = None | |
| # Reproject layers to graph CRS | |
| if crs is not None: | |
| try: | |
| if water is not None and hasattr(water, "to_crs") and not water.empty: | |
| water = water.to_crs(crs) | |
| except Exception: | |
| pass | |
| try: | |
| if waterways is not None and hasattr(waterways, "to_crs") and not waterways.empty: | |
| waterways = waterways.to_crs(crs) | |
| except Exception: | |
| pass | |
| try: | |
| if green is not None and hasattr(green, "to_crs") and not green.empty: | |
| green = green.to_crs(crs) | |
| except Exception: | |
| pass | |
| try: | |
| if forests is not None and hasattr(forests, "to_crs") and not forests.empty: | |
| forests = forests.to_crs(crs) | |
| except Exception: | |
| pass | |
| try: | |
| if buildings is not None and hasattr(buildings, "to_crs") and not buildings.empty: | |
| buildings = buildings.to_crs(crs) | |
| except Exception: | |
| pass | |
| try: | |
| if rails is not None and hasattr(rails, "to_crs") and not rails.empty: | |
| rails = rails.to_crs(crs) | |
| except Exception: | |
| pass | |
| try: | |
| if coast is not None and hasattr(coast, "to_crs") and crs is not None and not coast.empty: | |
| coast = coast.to_crs(crs) | |
| except Exception: | |
| pass | |
| # Clean geometries | |
| buildings = _clean_polygons(buildings) | |
| water = _clean_polygons(water) | |
| green = _clean_polygons(green) | |
| forests = _clean_polygons(forests) | |
| waterways = _clean_lines(waterways) | |
| rails = _clean_lines(rails) | |
| coast = _clean_lines(coast) | |
| if buildings is not None and not buildings.empty: | |
| try: | |
| buildings["geometry"] = buildings.geometry.simplify(1.5, preserve_topology=True) | |
| except Exception: | |
| pass | |
| # Optional rail noise filters | |
| if rails is not None and not getattr(rails, "empty", True): | |
| if "service" in rails.columns: | |
| rails = rails[rails["service"].isna()].copy() | |
| if "tunnel" in rails.columns: | |
| rails = rails[rails["tunnel"] != "yes"].copy() | |
| print("Rendering map...") | |
| map_format = THEME.get("_format", "poster") | |
| orientation = THEME.get("_orientation", "portrait") | |
| figsize = get_figsize(map_format, orientation) | |
| target_ratio = figsize[0] / figsize[1] | |
| # Fade thicknesses (only used inland when margins enabled) | |
| if map_format == "panoramic" and orientation == "landscape": | |
| fade_tb = 0.12 | |
| fade_lr = 0.22 | |
| elif orientation == "landscape": | |
| fade_tb = 0.15 | |
| fade_lr = 0.18 | |
| elif map_format == "plan": | |
| fade_tb = 0.18 | |
| fade_lr = 0.18 | |
| else: | |
| fade_tb = 0.22 | |
| fade_lr = 0.10 | |
| fig = plt.figure(figsize=figsize, facecolor=THEME["bg"]) | |
| ax_map = fig.add_axes([0, 0, 1, 1]) | |
| ax_map.set_axis_off() | |
| # ---- Coastline-aware ocean fill (optional) ---- | |
| land_poly = None | |
| ocean_poly = None | |
| if enable_coastline and bbox_poly is not None and coast is not None and not getattr(coast, "empty", True): | |
| land_poly = coastline_land_polygon(coast, point, crs, bbox_poly) | |
| if land_poly is not None: | |
| ocean_poly = bbox_poly.difference(land_poly) | |
| # Paint ocean + land if available | |
| if ocean_poly is not None: | |
| try: | |
| gpd.GeoSeries([ocean_poly], crs=crs).plot( | |
| ax=ax_map, facecolor=THEME["water"], edgecolor="none", zorder=0 | |
| ) | |
| gpd.GeoSeries([land_poly], crs=crs).plot( | |
| ax=ax_map, facecolor=THEME["bg"], edgecolor="none", zorder=0.5 | |
| ) | |
| print("✓ Coastline detected: ocean fill enabled.") | |
| except Exception: | |
| ocean_poly = None | |
| land_poly = None | |
| is_coastal = (ocean_poly is not None) | |
| # Fallback background | |
| ax_map.set_facecolor(THEME["bg"] if not is_coastal else THEME["water"]) | |
| ax_page = fig.add_axes([0, 0, 1, 1], facecolor="none") | |
| ax_page.set_axis_off() | |
| # ---- Layer 1: polygons (bottom) ---- | |
| if water is not None and not getattr(water, "empty", True): | |
| water.plot(ax=ax_map, facecolor=THEME["water"], edgecolor="none", zorder=0.6) | |
| if green is not None and not getattr(green, "empty", True): | |
| green.plot(ax=ax_map, facecolor=THEME["parks"], edgecolor="none", zorder=0.7) | |
| # Forests (separate optional layer) | |
| if enable_forests and forests is not None and not getattr(forests, "empty", True): | |
| forest_color = THEME.get("forests", THEME.get("parks", "#F0F0F0")) | |
| forest_alpha = float(THEME.get("forests_alpha", 1.0)) | |
| forest_alpha = max(0.05, min(1.0, forest_alpha)) | |
| forests.plot(ax=ax_map, facecolor=forest_color, edgecolor="none", alpha=forest_alpha, zorder=0.75) | |
| # ---- Waterways (optional) ---- | |
| if enable_waterways and waterways is not None and not getattr(waterways, "empty", True): | |
| plot_waterways_by_hierarchy( | |
| ax_map, | |
| waterways, | |
| color=THEME["water"], | |
| theme=THEME, | |
| zorder=2.2, | |
| dash=None, | |
| ) | |
| # ---- Buildings ---- | |
| if enable_buildings and buildings is not None and not getattr(buildings, "empty", True): | |
| face, alpha = buildings_style_from_text(THEME) | |
| buildings.plot(ax=ax_map, facecolor=face, edgecolor="none", alpha=alpha, zorder=3.0) | |
| # ---- Roads ---- | |
| print("Applying road hierarchy colors...") | |
| edge_colors = get_edge_colors_by_type(G) | |
| edge_widths = get_edge_widths_by_type(G) | |
| bgcolor_roads = THEME["water"] if is_coastal else THEME["bg"] | |
| ox.plot_graph( | |
| G, | |
| ax=ax_map, | |
| bgcolor=bgcolor_roads, | |
| node_size=0, | |
| node_alpha=0, | |
| node_color=bgcolor_roads, | |
| node_edgecolor=bgcolor_roads, | |
| node_zorder=-1, | |
| edge_color=edge_colors, | |
| edge_linewidth=edge_widths, | |
| show=False, | |
| close=False, | |
| ) | |
| # Ensure plotted roads sit above polygons/waterways | |
| from matplotlib.collections import LineCollection | |
| for coll in ax_map.collections: | |
| if isinstance(coll, LineCollection): | |
| coll.set_zorder(2.6) | |
| # ---- Railroads ---- | |
| if enable_railroads and rails is not None and not getattr(rails, "empty", True): | |
| rail_color = THEME.get("rail_color", THEME.get("text", "#000000")) | |
| rail_width = float(THEME.get("rail_width", 0.6)) | |
| rail_alpha = float(THEME.get("rail_alpha", 0.55)) | |
| plot_lines_dashed( | |
| ax_map, | |
| rails, | |
| color=rail_color, | |
| lw=rail_width, | |
| alpha=rail_alpha, | |
| zorder=4.0, | |
| dash=(0, (3, 3)), | |
| ) | |
| # ---- Hard clamp view to bbox ---- | |
| if bbox_poly is not None: | |
| minx, miny, maxx, maxy = bbox_poly.bounds | |
| ax_map.set_xlim(minx, maxx) | |
| ax_map.set_ylim(miny, maxy) | |
| ax_map.margins(0) | |
| # ---- Cropping + edge treatment ---- | |
| frame_t = 0.0 | |
| if enable_margins: | |
| if not is_coastal: | |
| finalize_map_view(ax_map, target_ratio, pad=MAP_PAD) | |
| if orientation == "portrait": | |
| create_gradient_fade(ax_map, THEME["gradient_color"], "bottom", thickness=fade_tb) | |
| create_gradient_fade(ax_map, THEME["gradient_color"], "top", thickness=fade_tb) | |
| elif orientation == "landscape": | |
| create_gradient_fade(ax_map, THEME["gradient_color"], "left", thickness=fade_lr) | |
| create_gradient_fade(ax_map, THEME["gradient_color"], "right", thickness=fade_lr) | |
| else: | |
| finalize_map_view(ax_map, target_ratio, pad=0.0) | |
| frame_t = margin_thickness_from_layout(THEME) | |
| add_margin_frame(ax_page, color=THEME["bg"], thickness=frame_t, zorder=10) | |
| else: | |
| finalize_map_view(ax_map, target_ratio, pad=0.0) | |
| if THEME.get("_text_fade", False): | |
| add_bottom_text_fade( | |
| ax_page=ax_page, | |
| color=THEME["bg"], | |
| y0=0.0, | |
| height=0.40, | |
| alpha_max=0.80, | |
| zorder=10, | |
| ) | |
| # ---- Bottom text ---- | |
| city_label = city or "Custom" | |
| lat, lon = point | |
| coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E" | |
| if lon < 0: | |
| coords = coords.replace("E", "W") | |
| text_offset = 0.0 | |
| if enable_margins and is_coastal: | |
| text_offset = frame_t | |
| layout_bottom_text( | |
| ax_page=ax_page, | |
| city_text=city_label.upper(), | |
| country_text=(country or "").upper(), | |
| coords_text=coords, | |
| theme=THEME, | |
| fonts=FONTS, | |
| zorder=11, | |
| bottom_offset=text_offset, | |
| ) | |
| dpi = int(THEME.get("_dpi", DEFAULT_DPI)) | |
| print(f"Saving to {output_file}...") | |
| plt.savefig(output_file, dpi=dpi, facecolor=THEME["bg"]) | |
| plt.close() | |
| print(f"✓ Done! Poster saved as {output_file}") | |
| def print_examples(): | |
| print(""" | |
| City Map Poster Generator | |
| ========================= | |
| Usage: | |
| python create_map_poster.py --city <city> --country <country> [options] | |
| Examples: | |
| python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 | |
| python create_map_poster.py -c "Cahors" -C "France" -t noir -d 4000 | |
| Formats: | |
| python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format poster --orientation portrait | |
| python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format postcard --orientation landscape | |
| python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format plan | |
| python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format panoramic --orientation landscape | |
| Buildings (small scale): | |
| python create_map_poster.py -c "Cahors" -C "France" -t noir -d 5000 --buildings | |
| """) | |
| def list_themes(): | |
| available_themes = get_available_themes() | |
| if not available_themes: | |
| print("No themes found in 'themes/' directory.") | |
| return | |
| print("\nAvailable Themes:") | |
| print("-" * 60) | |
| for theme_name in available_themes: | |
| theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json") | |
| try: | |
| with open(theme_path, 'r') as f: | |
| theme_data = json.load(f) | |
| display_name = theme_data.get('name', theme_name) | |
| description = theme_data.get('description', '') | |
| except Exception: | |
| display_name = theme_name | |
| description = '' | |
| print(f" {theme_name}") | |
| print(f" {display_name}") | |
| if description: | |
| print(f" {description}") | |
| print() | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser( | |
| description="Generate beautiful map posters for any city", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| python create_map_poster.py --city "Paris" --country "France" --theme noir --distance 10000 | |
| python create_map_poster.py --city "Paris" --country "France" --theme noir --distance 10000 --format plan | |
| python create_map_poster.py --list-themes | |
| """ | |
| ) | |
| parser.add_argument('--city', '-c', type=str, help='City name') | |
| parser.add_argument('--country', '-C', type=str, help='Country name') | |
| parser.add_argument('--lat', type=float, help='Latitude to center the map (optional)') | |
| parser.add_argument('--lon', type=float, help='Longitude to center the map (optional)') | |
| parser.add_argument('--theme', '-t', type=str, default='feature_based', | |
| help='Theme name (default: feature_based)') | |
| parser.add_argument('--distance', '-d', type=int, default=29000, | |
| help='Map radius in meters (default: 29000)') | |
| parser.add_argument('--list-themes', action='store_true', help='List all available themes') | |
| parser.add_argument( | |
| '--format', | |
| type=str, | |
| default='poster', | |
| choices=['poster', 'postcard', 'plan', 'panoramic'], | |
| help='Output format (default: poster)' | |
| ) | |
| parser.add_argument( | |
| '--orientation', | |
| type=str, | |
| default='portrait', | |
| choices=['portrait', 'landscape'], | |
| help='Orientation (default: portrait). Ignored for format=plan.' | |
| ) | |
| parser.add_argument( | |
| '--dpi', | |
| type=int, | |
| default=DEFAULT_DPI, | |
| help=f'Output DPI (default: {DEFAULT_DPI})' | |
| ) | |
| parser.add_argument( | |
| "--text-fade", | |
| action="store_true", | |
| help="Enable fade under bottom text" | |
| ) | |
| parser.add_argument( | |
| '--no-margins', | |
| action='store_true', | |
| help='Disable inland fading and coastal margin frame.' | |
| ) | |
| parser.add_argument( | |
| '--buildings', | |
| action='store_true', | |
| help=f'Enable buildings layer (only effective when distance <= {BUILDINGS_MAX_DIST}m).' | |
| ) | |
| parser.add_argument( | |
| '--railroads', | |
| action='store_true', | |
| help='Enable railroads layer (OSM railway=rail).' | |
| ) | |
| parser.add_argument( | |
| '--waterways', | |
| action='store_true', | |
| help='Enable waterways layer (rivers, streams, canals, etc.)' | |
| ) | |
| parser.add_argument( | |
| '--forests', | |
| action='store_true', | |
| help='Enable forests layer (wooded areas)' | |
| ) | |
| parser.add_argument( | |
| '--coastline', | |
| action='store_true', | |
| help='Enable coastline detection and ocean fill' | |
| ) | |
| args = parser.parse_args() | |
| if len(os.sys.argv) == 1: | |
| print_examples() | |
| os.sys.exit(0) | |
| if args.list_themes: | |
| list_themes() | |
| os.sys.exit(0) | |
| if not args.city or not args.country: | |
| print("Error: --city and --country are required.\n") | |
| print_examples() | |
| os.sys.exit(1) | |
| available_themes = get_available_themes() | |
| if args.theme not in available_themes: | |
| print(f"Error: Theme '{args.theme}' not found.") | |
| print(f"Available themes: {', '.join(available_themes)}") | |
| os.sys.exit(1) | |
| print("=" * 50) | |
| print("City Map Poster Generator") | |
| print("=" * 50) | |
| THEME = load_theme(args.theme) | |
| THEME["_format"] = args.format | |
| THEME["_orientation"] = args.orientation | |
| THEME["_dpi"] = args.dpi | |
| THEME["_text_fade"] = args.text_fade | |
| try: | |
| using_coords = (args.lat is not None and args.lon is not None) | |
| if not using_coords: | |
| if not args.city or not args.country: | |
| print("Error: --city and --country are required (unless --lat/--lon are provided).\n") | |
| print_examples() | |
| os.sys.exit(1) | |
| if args.lat is not None and args.lon is not None: | |
| coords = (args.lat, args.lon) | |
| else: | |
| coords = get_coordinates(args.city, args.country) | |
| output_file = generate_output_filename(args.city, args.theme) | |
| create_poster( | |
| args.city, | |
| args.country, | |
| coords, | |
| args.distance, | |
| output_file, | |
| enable_buildings=bool(args.buildings), | |
| enable_railroads=bool(args.railroads), | |
| enable_margins=(not bool(args.no_margins)), | |
| enable_waterways=bool(args.waterways), | |
| enable_forests=bool(args.forests), | |
| enable_coastline=bool(args.coastline), | |
| ) | |
| print("\n" + "=" * 50) | |
| print("✓ Poster generation complete!") | |
| print("=" * 50) | |
| except Exception as e: | |
| print(f"\n✗ Error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| os.sys.exit(1) |