import os import math import json import re import unicodedata import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont # ========================= # CONSTANTS & CONFIG # ========================= ASSETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") MAP_STYLE_DIR = os.path.join(ASSETS_DIR, "map_styles") CITIES_MASTER_FILE = os.path.join(ASSETS_DIR, "cities_master.json") ANCHORS_DEFAULT_FILE = os.path.join(ASSETS_DIR, "anchors_default.json") # ========================= # DEFAULT COLORS (used as fallback if style pack missing a key) # ========================= COLOR_PLANE = (0x1F, 0x27, 0xD8, 255) COLOR_CAR = (0x1F, 0x27, 0xD8, 255) COLOR_BOAT = (0x1F, 0x27, 0xD8, 255) COLOR_TRAIN = (0xC3, 0x0F, 0x16, 255) MARKER_COLOR_VN = (0x33, 0x99, 0x00, 255) MARKER_COLOR_FOREIGN = (0xFF, 0x00, 0x00, 255) # New style-pack system used by the current app runtime. # If blank maps or replacement icons are dropped into assets/map_styles/style1|style2, # the renderer will pick them up automatically without more code changes. STYLE_ALIASES = { "default": "style1", "tonkin": "style1", "classic": "style2", "warm": "style2", } BASE_MAP_ALIASES = { "Vietnam": "Vietnam Only", "VN+Laos+Cambodia": "Indochina", "VN Laos Cambodia": "Indochina", "Vietnam Laos Cambodia": "Indochina", "SoutheastAsia": "Southeast Asia", "SEA": "Southeast Asia", } def normalize_base_map_name(base_map_name): name = (base_map_name or "Vietnam Only").strip() return BASE_MAP_ALIASES.get(name, name) def _style_asset_path(style_name, *parts): return os.path.join(MAP_STYLE_DIR, style_name, *parts) STYLE_PACKS = { "style1": { "name": "Style 1", "description": "Default clean route style.", "icon_scale": 1.0, "line_width_scale": 1.0, "dash_patterns": { "plane": (12, 8), "arrival_departure": (12, 8), }, "mode_icon_scale": { "plane": 1.0, }, "mode_offset_scale": { "car": 0.85, "boat": 1.05, "train": 1.3, }, "colors": { "plane": (0x00, 0x00, 0xFF, 255), "car": (0x00, 0x00, 0xFF, 255), "boat": (0x33, 0x99, 0x00, 255), "train": (0xC3, 0x0F, 0x16, 255), "marker_vn": (0x33, 0x99, 0x00, 255), "marker_foreign": (0xFF, 0x00, 0x00, 255), "arrival_departure": (0x00, 0x00, 0xFF, 255), }, "maps": { "Vietnam": { "blank_candidates": [ _style_asset_path("style1", "base_map_vietnam_only_blank.png"), _style_asset_path("style1", "base_map_vietnam_only_blank.jpg"), _style_asset_path("style1", "base_map_vietnam_blank.png"), _style_asset_path("style1", "base_map_vietnam_blank.jpg"), _style_asset_path("style1", "base_map_vietnam.png"), _style_asset_path("style1", "base_map_vietnam.jpg"), ], "legacy_fallback": os.path.join(ASSETS_DIR, "base_map.jpg"), }, "Vietnam Only": { "blank_candidates": [ _style_asset_path("style1", "base_map_vietnam_only_blank.png"), _style_asset_path("style1", "base_map_vietnam_only_blank.jpg"), _style_asset_path("style1", "base_map_vietnam_blank.png"), _style_asset_path("style1", "base_map_vietnam_blank.jpg"), _style_asset_path("style1", "base_map_vietnam.png"), _style_asset_path("style1", "base_map_vietnam.jpg"), ], "legacy_fallback": os.path.join(ASSETS_DIR, "base_map.jpg"), }, "Indochina": { "blank_candidates": [ _style_asset_path("style1", "base_map_indochina_blank.png"), _style_asset_path("style1", "base_map_indochina_blank.jpg"), _style_asset_path("style1", "base_map_vn+laos+cambodia_blank.png"), _style_asset_path("style1", "base_map_vn+laos+cambodia_blank.jpg"), ], "legacy_fallback": None, }, "Southeast Asia": { "blank_candidates": [ _style_asset_path("style1", "base_map_southeast_asia_blank.png"), _style_asset_path("style1", "base_map_southeast_asia_blank.jpg"), _style_asset_path("style1", "base_map_southeast_asia.png"), _style_asset_path("style1", "base_map_southeast_asia.jpg"), _style_asset_path("style1", "base_map_indochina.png"), _style_asset_path("style1", "base_map_indochina.jpg"), ], "legacy_fallback": os.path.join(ASSETS_DIR, "base_map_indochina.jpg"), }, }, "icons": { "plane": [_style_asset_path("style1", "plane.png"), os.path.join(ASSETS_DIR, "plane2.png")], "car": [_style_asset_path("style1", "car.png"), os.path.join(ASSETS_DIR, "car2.png")], "boat": [_style_asset_path("style1", "boat.png"), os.path.join(ASSETS_DIR, "boat2.png")], "train": [_style_asset_path("style1", "train.png"), os.path.join(ASSETS_DIR, "train2.png")], }, }, "style2": { "name": "Style 2", "description": "Alternative icon pack with larger transport markers.", "icon_scale": 1.35, "line_width_scale": 1.2, "dash_patterns": { "plane": (12, 8), "arrival_departure": (12, 8), }, "mode_icon_scale": { "plane": 0.82, "car": 74 / (40 * 1.35), # 74 px final output width }, "mode_offset_scale": { "car": 1.15, "boat": 1.3, "train": 1.25, }, "colors": { "plane": (0x36, 0x6A, 0x93, 255), "car": (0x36, 0x6A, 0x93, 255), "boat": (0x33, 0x99, 0x00, 255), "train": (0xDC, 0x14, 0x3C, 255), "marker_vn": (0x22, 0x8B, 0x22, 255), "marker_foreign": (0xFF, 0x45, 0x00, 255), "arrival_departure": (0x36, 0x6A, 0x93, 255), }, "maps": { "Vietnam": { "blank_candidates": [ _style_asset_path("style2", "base_map_vietnam_only_blank.png"), _style_asset_path("style2", "base_map_vietnam_only_blank.jpg"), _style_asset_path("style2", "base_map_vietnam_blank.png"), _style_asset_path("style2", "base_map_vietnam_blank.jpg"), _style_asset_path("style2", "base_map_vietnam.png"), _style_asset_path("style2", "base_map_vietnam.jpg"), ], "legacy_fallback": os.path.join(ASSETS_DIR, "base_map.jpg"), }, "Vietnam Only": { "blank_candidates": [ _style_asset_path("style2", "base_map_vietnam_only_blank.png"), _style_asset_path("style2", "base_map_vietnam_only_blank.jpg"), _style_asset_path("style2", "base_map_vietnam_blank.png"), _style_asset_path("style2", "base_map_vietnam_blank.jpg"), _style_asset_path("style2", "base_map_vietnam.png"), _style_asset_path("style2", "base_map_vietnam.jpg"), ], "legacy_fallback": os.path.join(ASSETS_DIR, "base_map.jpg"), }, "Indochina": { "blank_candidates": [ _style_asset_path("style2", "base_map_indochina_blank.png"), _style_asset_path("style2", "base_map_indochina_blank.jpg"), _style_asset_path("style2", "base_map_vn+laos+cambodia_blank.png"), _style_asset_path("style2", "base_map_vn+laos+cambodia_blank.jpg"), ], "legacy_fallback": None, }, "Southeast Asia": { "blank_candidates": [ _style_asset_path("style2", "base_map_southeast_asia_blank.png"), _style_asset_path("style2", "base_map_southeast_asia_blank.jpg"), _style_asset_path("style2", "base_map_southeast_asia.png"), _style_asset_path("style2", "base_map_southeast_asia.jpg"), _style_asset_path("style2", "base_map_indochina.png"), _style_asset_path("style2", "base_map_indochina.jpg"), ], "legacy_fallback": os.path.join(ASSETS_DIR, "base_map_indochina.jpg"), }, }, "icons": { "plane": [_style_asset_path("style2", "plane.png"), os.path.join(ASSETS_DIR, "plane2.png")], "car": [_style_asset_path("style2", "car.png"), os.path.join(ASSETS_DIR, "car2.png")], "boat": [_style_asset_path("style2", "boat.png"), os.path.join(ASSETS_DIR, "boat2.png")], "speedboat": [_style_asset_path("style2", "speedboat.png")], "train": [_style_asset_path("style2", "train.png"), os.path.join(ASSETS_DIR, "train2.png")], }, }, } RACH_GIA_PHU_QUOC_PAIR = frozenset(("Rach Gia", "Phu Quoc")) STYLE2_SPEEDBOAT_ROUTE_PAIRS = { RACH_GIA_PHU_QUOC_PAIR, frozenset(("Con Dao", "Phu Quoc")), } CENTRAL_VIETNAM_CAR_ICON_LEFT_CITIES = {"Hue", "Da Nang", "Hoi An"} NINH_BINH_HA_LONG_CAR_ICON_PAIR = frozenset(("Ninh Binh", "Ha Long")) INLAND_TRAIN_ROUTE_PAIRS = { frozenset(("Ha Noi", "Hue")), frozenset(("Ha Noi", "Da Nang")), } VIETNAM_COASTAL_TRAIN_ROUTE_PAIRS = { frozenset(("Da Nang", "Sai Gon")), frozenset(("Hue", "Sai Gon")), } DEFAULT_LANDMARKS_BY_BASE_MAP = { "Vietnam": ["Ha Noi", "Ha Long", "Hue", "Da Nang", "Hoi An", "Sai Gon"], "Vietnam Only": ["Ha Noi", "Ha Long", "Hue", "Da Nang", "Hoi An", "Sai Gon"], "Indochina": [ "Ha Noi", "Ha Long", "Hue", "Da Nang", "Hoi An", "Sai Gon", "Luang Prabang", "Vientiane", "Pakse", "Siem Reap", "Phnom Penh", ], "VN+Laos+Cambodia": [ "Ha Noi", "Ha Long", "Hue", "Da Nang", "Hoi An", "Sai Gon", "Luang Prabang", "Vientiane", "Pakse", "Siem Reap", "Phnom Penh", ], "Southeast Asia": [ "Ha Noi", "Ha Long", "Hue", "Da Nang", "Hoi An", "Sai Gon", "Luang Prabang", "Vientiane", "Pakse", "Siem Reap", "Phnom Penh", "Bangkok", ], "Cambodia": ["Siem Reap", "Phnom Penh", "Battambang", "Sihanoukville"], "Laos": ["Luang Prabang", "Vientiane", "Pakse"], "Thailand": ["Bangkok", "Chiang Mai", "Phuket"], "Myanmar": ["Yangon", "Mandalay", "Bagan", "Inle Lake"], } CITY_POSITION_OVERRIDES = { ("Vietnam Only", "style2"): { # The illustrated Vietnam Style 2 base map draws Phu Quoc lower than # the affine lat/lon projection, so pin it to the island artwork. # Keyed by base-map style, not icon asset style. "Phu Quoc": (0.2796, 0.9010), }, } def _first_existing(paths): for path in paths: if path and os.path.exists(path): return path return None import glob as _glob def list_base_map_variants(base_map_name, style_name="style1"): """Return list of available variant labels for a base map (e.g. ['default', '1', '2']).""" resolved_name = STYLE_ALIASES.get(style_name, style_name) base_map_name = normalize_base_map_name(base_map_name) slug = base_map_name.lower().replace(" ", "_") style_dir = os.path.join(MAP_STYLE_DIR, resolved_name) variants = [] # Check default (no suffix) for ext in ("png", "jpg"): if os.path.exists(os.path.join(style_dir, f"base_map_{slug}_blank.{ext}")): variants.append("default") break if os.path.exists(os.path.join(style_dir, f"base_map_{slug}.{ext}")): variants.append("default") break # Scan numbered variants (_1, _2, _3...) for f in sorted(_glob.glob(os.path.join(style_dir, f"base_map_{slug}_blank_*.*"))): fname = os.path.basename(f) # Extract the variant number/label from e.g. base_map_vietnam_only_blank_2.png stem = os.path.splitext(fname)[0] # base_map_vietnam_only_blank_2 suffix = stem.replace(f"base_map_{slug}_blank_", "") # "2" if suffix and suffix not in variants: variants.append(suffix) return variants if variants else ["default"] def resolve_style_pack(style_name, base_map_name, variant=None, base_map_style=None): resolved_name = STYLE_ALIASES.get(style_name, style_name) style_cfg = STYLE_PACKS.get(resolved_name, STYLE_PACKS["style1"]) resolved_map_style = STYLE_ALIASES.get(base_map_style or resolved_name, base_map_style or resolved_name) map_style_cfg = STYLE_PACKS.get(resolved_map_style, style_cfg) base_map_name = normalize_base_map_name(base_map_name) map_cfg = map_style_cfg["maps"].get(base_map_name) slug = base_map_name.lower().replace(" ", "_") # If a numbered variant is specified (e.g. "1", "2"), look for that file first variant_candidates = [] if variant and variant != "default": variant_candidates = [ _style_asset_path(resolved_map_style, f"base_map_{slug}_blank_{variant}.png"), _style_asset_path(resolved_map_style, f"base_map_{slug}_blank_{variant}.jpg"), _style_asset_path(resolved_map_style, f"base_map_{slug}_{variant}.png"), _style_asset_path(resolved_map_style, f"base_map_{slug}_{variant}.jpg"), ] if not map_cfg: # Auto-generate candidates for any country name map_cfg = { "blank_candidates": [ _style_asset_path(resolved_map_style, f"base_map_{slug}_blank.png"), _style_asset_path(resolved_map_style, f"base_map_{slug}_blank.jpg"), _style_asset_path(resolved_map_style, f"base_map_{slug}.png"), _style_asset_path(resolved_map_style, f"base_map_{slug}.jpg"), ], "legacy_fallback": None, } # Try variant first, then default candidates all_candidates = variant_candidates + map_cfg.get("blank_candidates", []) blank_map = _first_existing(all_candidates) if not blank_map and not map_cfg.get("legacy_fallback"): # No image found for this country — fall back to Vietnam map_cfg = map_style_cfg["maps"]["Vietnam"] blank_map = _first_existing(map_cfg.get("blank_candidates", [])) base_map_path = blank_map or map_cfg.get("legacy_fallback") icons = { mode: _first_existing(candidates) for mode, candidates in style_cfg.get("icons", {}).items() } return { "key": resolved_name, "base_map_style": resolved_map_style, "name": style_cfg["name"], "description": style_cfg["description"], "colors": style_cfg["colors"], "icon_scale": style_cfg.get("icon_scale", 1.0), "line_width_scale": style_cfg.get("line_width_scale", 1.0), "dash_patterns": style_cfg.get("dash_patterns", {}), "mode_icon_scale": style_cfg.get("mode_icon_scale", {}), "mode_offset_scale": style_cfg.get("mode_offset_scale", {}), "base_map_path": base_map_path, "legacy_fallback_path": map_cfg.get("legacy_fallback"), "has_preprinted_labels": not bool(blank_map), "icons": icons, } def resolve_anchor_preset_name(base_map_name, style_pack=None, base_map_path=None): """Pick the best anchor preset for the actual base map file in use.""" path = (base_map_path or (style_pack or {}).get("base_map_path") or "").lower() normalized = normalize_base_map_name(base_map_name) if "vietnam_only" in path: return "Vietnam Only" if "southeast_asia" in path: return "Southeast Asia" if "vn+laos+cambodia" in path or "vn_laos_cambodia" in path: return "Indochina" return normalized MARKER_R = 6 DASH_ON, DASH_OFF = 12, 6 ARROW_SIZE = 18 CURVE_BULGE = 0.25 LABEL_STROKE_W = 2 SUPERSAMPLE_DEFAULT = 3 # Default anchors. The runtime writes/reads these from assets/anchors_default.json. DEFAULT_ANCHORS_PRESETS = { "Southeast Asia": { "Ha Noi": (0.641103, 0.170993), "Bangkok": (0.297468, 0.5510993191489362), "Sai Gon": (0.6925316817359854, 0.702836439716312), }, "Vietnam": { "Ha Noi": (0.507103, 0.185993), "Hue": (0.74, 0.49), "Sai Gon": (0.63934, 0.843918), }, "Vietnam Only": { "Ha Noi": (0.4829, 0.1995), "Hue": (0.6884, 0.4892), "Sai Gon": (0.5762, 0.8460), }, "Cambodia": { "Siem Reap": (0.3739, 0.3223), "Battambang": (0.2899, 0.3695), "Phnom Penh": (0.5122, 0.6241), "Sihanoukville": (0.3294, 0.7812), "Kampot": (0.4159, 0.7844), "Kep": (0.4332, 0.8064), }, "Laos": { "Luang Prabang": (0.3142, 0.3178), "Pak Ou": (0.3229, 0.3029), "Khuang Si": (0.2968, 0.3327), "Vang Vieng": (0.3489, 0.4148), "Vientiane": (0.3706, 0.5080), "Pakse": (0.7264, 0.7915), }, "Thailand": { "Bangkok": (0.4265, 0.4414), "Chiang Mai": (0.2337, 0.1055), "Chiang Rai": (0.3425, 0.0305), "Phuket": (0.1570, 0.8336), "Krabi": (0.2238, 0.8198), "Koh Samui": (0.3721, 0.7224), "Pattaya": (0.4760, 0.4964), "Ayutthaya": (0.4364, 0.4014), "Sukhothai": (0.3400, 0.2241), "Pai": (0.1645, 0.0680), "Mae Hong Son": (0.1027, 0.0717), "Kanchanaburi": (0.3029, 0.4239), }, "Indochina": { "Ha Noi": (0.5756, 0.2173), "Hue": (0.7104, 0.4920), "Da Nang": (0.7593, 0.5157), "Hoi An": (0.7682, 0.5270), "Sai Gon": (0.6378, 0.8322), "Luang Prabang": (0.2926, 0.2863), "Vientiane": (0.3311, 0.4016), "Pakse": (0.5741, 0.5733), "Siem Reap": (0.4244, 0.6796), "Phnom Penh": (0.5074, 0.7881), }, "Myanmar": { "Yangon": (0.50, 0.75), "Mandalay": (0.50, 0.40), "Bagan": (0.35, 0.45), }, } ANCHORS_PRESETS = DEFAULT_ANCHORS_PRESETS # Multi-language translations (FR/EN/IT - no Vietnamese) TRANSLATIONS = { "FR": {"arrival": "Arrivée", "departure": "Départ"}, "EN": {"arrival": "Arrival", "departure": "Departure"}, "IT": {"arrival": "Arrivo", "departure": "Partenza"} } # ========================= # CITY DATABASE # ========================= class City: def __init__(self, name, lat, lon, country="VN", aliases=None, label_ang=None, label_dist=None): self.name = name self.lat = float(lat) self.lon = float(lon) self.country = country self.aliases = aliases if aliases else [] self.label_ang = label_ang self.label_dist = label_dist SEED_CITIES = [ # ============================================================================ # LABEL POSITION GUIDE: # label_ang: Angle in degrees (0=→right, 90=↓down, -90=↑up, 180=←left, 45=↘, -45=↗, etc.) # label_dist: Distance multiplier (1.0=default 8px×SS, 1.5=12px×SS, 2.0=16px×SS) # Adjust these values to fine-tune label positions! # ============================================================================ # VN - NORTH (Hanoi cluster) City("Ha Noi", 21.0278, 105.8342, "VN", ["Hanoi","Ha-Noi","Noi Bai","Noi Bai Airport"], label_ang=0, label_dist=1.0), City("Ha Long", 20.9714, 107.0448, "VN", ["Halong","Tuan Chau","Lan Ha","Cat Ba"], label_ang=0, label_dist=1.5), City("Ninh Binh", 20.2574, 105.9797, "VN", ["Tam Coc","Trang An"], label_ang=90, label_dist=1.2), City("Hai Phong", 20.8449, 106.6881, "VN", ["Haiphong"], label_ang=-90, label_dist=1.0), City("Cat Ba", 20.8, 107.05, "VN", [], label_ang=45, label_dist=1.0), City("Bac Ninh", 21.1861, 106.0763, "VN", [], label_ang=-90, label_dist=1.0), City("Thai Nguyen", 21.5671, 105.8252, "VN", [], label_ang=-45, label_dist=1.0), City("Quang Ninh", 21.0064, 107.2925, "VN", [], label_ang=0, label_dist=1.0), # VN - NORTHWEST (Sapa cluster) City("Sapa", 22.3364, 103.8438, "VN", ["Sa Pa", "Lao Cai"], label_ang=-90, label_dist=1.0), City("Ha Giang", 22.8233, 104.9836, "VN", [], label_ang=-45, label_dist=1.0), City("Bac Ha", 22.5333, 104.3000, "VN", [], label_ang=0, label_dist=1.0), City("Yen Bai", 21.7167, 104.8667, "VN", [], label_ang=180, label_dist=1.0), City("Cao Bang", 22.6667, 106.2500, "VN", [], label_ang=0, label_dist=1.0), City("Lang Son", 21.8536, 106.7610, "VN", [], label_ang=0, label_dist=1.0), City("Tuyen Quang", 21.8167, 105.2167, "VN", [], label_ang=-90, label_dist=1.0), City("Phu Tho", 21.2689, 105.2045, "VN", [], label_ang=180, label_dist=1.0), City("Bac Giang", 21.2819, 106.1973, "VN", [], label_ang=90, label_dist=1.0), # VN - NORTHWEST MOUNTAINS (Mai Chau cluster) City("Mai Chau", 20.6603, 105.0786, "VN", [], label_ang=180, label_dist=1.0), City("Pu Luong", 20.4682, 105.1869, "VN", [], label_ang=90, label_dist=1.0), City("Hoa Binh", 20.8136, 105.3388, "VN", [], label_ang=-90, label_dist=1.0), City("Moc Chau", 20.8333, 104.6833, "VN", [], label_ang=180, label_dist=1.0), City("Son La", 21.3167, 103.9000, "VN", [], label_ang=-90, label_dist=1.0), City("Dien Bien Phu", 21.3833, 103.0167, "VN", ["Dien Bien"], label_ang=180, label_dist=1.0), # VN - RED RIVER DELTA City("Nam Dinh", 20.4167, 106.1667, "VN", [], label_ang=90, label_dist=1.0), City("Thai Binh", 20.4500, 106.3333, "VN", [], label_ang=0, label_dist=1.0), City("Ha Nam", 20.5833, 105.9167, "VN", [], label_ang=180, label_dist=1.0), # VN - NORTH CENTRAL (Vinh - Dong Hoi) City("Thanh Hoa", 19.8067, 105.7761, "VN", [], label_ang=180, label_dist=1.0), City("Vinh", 18.6792, 105.6919, "VN", [], label_ang=180, label_dist=1.0), City("Dong Hoi", 17.4833, 106.6000, "VN", ["Phong Nha"], label_ang=0, label_dist=1.2), City("Quang Tri", 16.7943, 107.1856, "VN", [], label_ang=-90, label_dist=1.0), # VN - CENTRAL (Hue - Da Nang - Hoi An cluster) - IMPORTANT: spread in different directions! City("Hue", 16.4637, 107.5909, "VN", [], label_ang=-90, label_dist=1.2), City("Da Nang", 16.0678, 108.2208, "VN", ["Danang"], label_ang=0, label_dist=1.0), City("Hoi An", 15.8801, 108.338, "VN", ["Hoi-An","Ancient Town"], label_ang=45, label_dist=1.0), City("Thua Thien Hue", 16.4637, 107.5909, "VN", [], label_ang=135, label_dist=1.0), City("Quang Nam", 15.5394, 108.0191, "VN", [], label_ang=90, label_dist=1.0), City("Quang Ngai", 15.1214, 108.8044, "VN", [], label_ang=0, label_dist=1.0), # VN - SOUTH CENTRAL COAST City("Quy Nhon", 13.7830, 109.2194, "VN", [], label_ang=0, label_dist=1.0), City("Nha Trang", 12.2388, 109.1967, "VN", [], label_ang=0, label_dist=1.0), City("Phan Rang", 11.5667, 108.9833, "VN", [], label_ang=0, label_dist=1.0), City("Binh Dinh", 13.7829, 109.2196, "VN", [], label_ang=45, label_dist=1.0), City("Phu Yen", 13.0881, 109.0929, "VN", [], label_ang=-45, label_dist=1.0), City("Khanh Hoa", 12.2585, 109.0526, "VN", [], label_ang=90, label_dist=1.0), City("Ninh Thuan", 11.6739, 108.8629, "VN", [], label_ang=45, label_dist=1.0), # VN - CENTRAL HIGHLANDS City("Dalat", 11.9404, 108.4583, "VN", ["Da Lat"], label_ang=180, label_dist=1.0), City("Pleiku", 13.9833, 108.0000, "VN", [], label_ang=180, label_dist=1.0), City("Buon Ma Thuot", 12.6667, 108.0500, "VN", ["Buon Me Thuot","BMT"], label_ang=180, label_dist=1.0), City("Kon Tum", 14.3497, 108.0005, "VN", [], label_ang=-90, label_dist=1.0), # VN - SOUTHEAST (Saigon cluster) City("Sai Gon", 10.8231, 106.6297, "VN", ["Ho Chi Minh","Ho Chi Minh City","HCMC","Saigon","SGN"], label_ang=-17, label_dist=3.0), City("Mui Ne", 10.9333, 108.2833, "VN", ["Phan Thiet"], label_ang=0, label_dist=1.0), City("Vung Tau", 10.3460, 107.0843, "VN", [], label_ang=90, label_dist=1.0), City("Binh Thuan", 11.0904, 108.0721, "VN", [], label_ang=-90, label_dist=1.0), City("Con Dao", 8.6833, 106.6000, "VN", [], label_ang=90, label_dist=1.0), # VN - MEKONG DELTA City("Can Tho", 10.0452, 105.7469, "VN", [], label_ang=45, label_dist=1.8), City("Chau Doc", 10.702, 105.117, "VN", [], label_ang=180, label_dist=1.0), City("Phu Quoc", 10.2899, 103.9840, "VN", [], label_ang=-178, label_dist=9.9), City("Ben Tre", 10.2433, 106.3753, "VN", [], label_ang=0, label_dist=1.0), City("Rach Gia", 10.0107, 105.0833, "VN", [], label_ang=168, label_dist=7.9), City("Long Xuyen", 10.3867, 105.4350, "VN", [], label_ang=-90, label_dist=1.0), City("Sa Dec", 10.2917, 105.7569, "VN", [], label_ang=90, label_dist=1.0), City("Vinh Long", 10.2500, 105.9667, "VN", [], label_ang=45, label_dist=1.0), City("My Tho", 10.3600, 106.3600, "VN", [], label_ang=0, label_dist=1.0), City("Tra Vinh", 9.9347, 106.3456, "VN", [], label_ang=90, label_dist=1.0), City("Soc Trang", 9.6033, 105.9800, "VN", [], label_ang=90, label_dist=1.0), City("Ca Mau", 9.1769, 105.1500, "VN", [], label_ang=90, label_dist=1.0), City("Bac Lieu", 9.2833, 105.7333, "VN", [], label_ang=0, label_dist=1.0), # LAOS City("Luang Prabang", 19.8897, 102.135, "LA", ["Luang-Prabang","LP"], label_ang=90, label_dist=1.0), City("Pak Ou", 20.05, 102.21, "LA", ["Pak-Ou", "Pak Ou Cave"], label_ang=-90, label_dist=1.0), City("Khuang Si", 19.7486, 101.9931, "LA", ["Kuang Si", "Kuang Si Falls", "Tat Kuang Si"], label_ang=180, label_dist=1.0), City("Vientiane", 17.9757, 102.6331, "LA", ["Vieng Chan"], label_ang=0, label_dist=1.0), City("Vang Vieng", 18.925, 102.447, "LA", [], label_ang=0, label_dist=1.0), # CAMBODIA City("Siem Reap", 13.3622, 103.8597, "KH", ["Angkor","Tonle Sap"], label_ang=-18, label_dist=2.0), City("Phnom Penh", 11.5564, 104.9282, "KH", ["Pnom Penh"], label_ang=-56, label_dist=2.2), City("Battambang", 13.0957, 103.2022, "KH", [], label_ang=180, label_dist=1.0), City("Kampot", 10.6167, 104.1833, "KH", [], label_ang=90, label_dist=1.0), City("Kep", 10.4833, 104.3167, "KH", [], label_ang=0, label_dist=1.0), City("Sihanoukville", 10.6333, 103.5000, "KH", ["Kampong Som"], label_ang=180, label_dist=1.0), # THAILAND City("Bangkok", 13.7563, 100.5018, "TH", ["BKK","Suvarnabhumi"], label_ang=0, label_dist=1.0), City("Chiang Mai", 18.7883, 98.9853, "TH", ["CM"], label_ang=-90, label_dist=1.0), City("Chiang Rai", 19.9105, 99.8406, "TH", [], label_ang=-45, label_dist=1.0), City("Phuket", 7.8804, 98.3923, "TH", [], label_ang=180, label_dist=1.0), City("Krabi", 8.0863, 98.9063, "TH", [], label_ang=0, label_dist=1.0), City("Koh Samui", 9.5357, 100.0633, "TH", ["Samui"], label_ang=0, label_dist=1.0), City("Pattaya", 12.9236, 100.8825, "TH", [], label_ang=0, label_dist=1.0), City("Ayutthaya", 14.3532, 100.5775, "TH", [], label_ang=-90, label_dist=1.0), City("Sukhothai", 17.0061, 99.8230, "TH", [], label_ang=180, label_dist=1.0), City("Pai", 19.3581, 98.4405, "TH", [], label_ang=-90, label_dist=1.0), City("Mae Hong Son", 19.3014, 97.9656, "TH", [], label_ang=180, label_dist=1.0), City("Kanchanaburi", 14.0227, 99.5328, "TH", [], label_ang=180, label_dist=1.0), ] def _city_to_record(city): return { "name": city.name, "lat": city.lat, "lon": city.lon, "country": city.country, "aliases": list(city.aliases or []), "label_ang": city.label_ang, "label_dist": city.label_dist, } def _ensure_json_file(path, default_payload): if os.path.exists(path): return os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(default_payload, handle, ensure_ascii=False, indent=2) def _write_cities_master(cities): payload = [_city_to_record(city) for city in cities] os.makedirs(os.path.dirname(CITIES_MASTER_FILE), exist_ok=True) with open(CITIES_MASTER_FILE, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) def _load_anchor_presets(): default_payload = { base_name: {city_name: list(xy) for city_name, xy in anchors.items()} for base_name, anchors in DEFAULT_ANCHORS_PRESETS.items() } _ensure_json_file(ANCHORS_DEFAULT_FILE, default_payload) try: with open(ANCHORS_DEFAULT_FILE, "r", encoding="utf-8") as handle: raw_data = json.load(handle) except Exception: return DEFAULT_ANCHORS_PRESETS loaded = {} for base_name, anchor_blob in raw_data.items(): base_name = normalize_base_map_name(base_name) anchors = {} if isinstance(anchor_blob, dict) and "presets" in anchor_blob: default_list = anchor_blob.get("presets", {}).get("_default", []) for item in default_list: if isinstance(item, dict) and "name" in item and "xy" in item: anchors[item["name"]] = tuple(item["xy"]) elif isinstance(anchor_blob, dict): for city_name, xy in anchor_blob.items(): if isinstance(xy, dict) and "xy" in xy: anchors[city_name] = tuple(xy["xy"]) elif isinstance(xy, (list, tuple)) and len(xy) == 2: anchors[city_name] = tuple(xy) if anchors: loaded.setdefault(base_name, {}).update(anchors) return loaded or DEFAULT_ANCHORS_PRESETS def _load_seed_cities(): default_payload = [_city_to_record(city) for city in SEED_CITIES] _ensure_json_file(CITIES_MASTER_FILE, default_payload) try: with open(CITIES_MASTER_FILE, "r", encoding="utf-8") as handle: raw_data = json.load(handle) except Exception: return SEED_CITIES loaded = [] for item in raw_data: if not isinstance(item, dict): continue name = item.get("name") lat = item.get("lat") lon = item.get("lon") if name is None or lat is None or lon is None: continue loaded.append( City( name=name, lat=lat, lon=lon, country=item.get("country", "VN"), aliases=item.get("aliases", []), label_ang=item.get("label_ang"), label_dist=item.get("label_dist"), ) ) return loaded or SEED_CITIES ANCHORS_PRESETS = _load_anchor_presets() SEED_CITIES = _load_seed_cities() # OSM Nominatim API for geocoding OSM_NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" def nominatim_geocode(query, country_codes="vn,la,kh,th,mm", limit=1): """ Geocode a place name using OpenStreetMap Nominatim API. Returns dict with name, lat, lon, country or None if not found. """ import requests try: params = { "q": query, "format": "jsonv2", "limit": limit, "accept-language": "en", "countrycodes": country_codes } headers = {"User-Agent": "TonkinTourAssistant/1.0"} resp = requests.get(OSM_NOMINATIM_URL, params=params, headers=headers, timeout=5) resp.raise_for_status() data = resp.json() if data: item = data[0] lat = float(item.get("lat", 0)) lon = float(item.get("lon", 0)) display_name = item.get("display_name", query) # Extract country code from display_name or address country = "VN" # Default if "Lào" in display_name or "Laos" in display_name: country = "LA" elif "Campuchia" in display_name or "Cambodia" in display_name: country = "KH" elif "Thái Lan" in display_name or "Thailand" in display_name: country = "TH" elif "Myanmar" in display_name: country = "MM" return {"name": query, "lat": lat, "lon": lon, "country": country} except Exception as e: print(f"Nominatim geocode error for '{query}': {e}") return None LOCATION_SKIP_PHRASES = { "arrival", "arrivee", "arrive", "depart", "departure", "arrivo", "partenza", "visit", "visite", "visita", "excursion", "escursione", "free time", "temps libre", "tempo libero", "leisure", "with guide", "avec guide", "con guida", "without guide", "sans guide", "with driver", "avec chauffeur", "sans chauffeur", "private", "prive", "privato", "group", "en groupe", "gruppo", "english speaking", "french speaking", "francophone", "anglophone", "transfer", "transfert", "trasferimento", "shuttle", "breakfast", "lunch", "dinner", "meal", "petit dejeuner", "dejeuner", "diner", "colazione", "pranzo", "cena", "pasto", "night", "overnight", "accommodation", "spa", "massage", "cooking class", "cours de cuisine", "corso di cucina", "market", "shopping", "workshop", "atelier", "temple", "pagoda", "museum", "musee", "floating village", "village flottant", "villaggio galleggiante", } LOCATION_DROP_PREFIXES = [ r"^(?:day|jour|ngay|giorno)\s+\d+[:\s-]*", r"^(?:vol|flight|plane|volo)\s+(?:pour|vers|to|from|de|da|a)\s+", r"^(?:arrivee|arrival|arrivo|depart|departure|partenza)\s+(?:a|to|vers|de|from)?\s*", ] LOCATION_DROP_SUFFIXES = [ r"\s*\([^)]*\)\s*$", r"\s*\[[^\]]*\]\s*$", r"\s+(?:arrival|arrivee|arrivo|departure|depart|partenza).*$", r"\s+(?:visit|visite|visita|excursion|escursione|free time|temps libre|tempo libero).*$", r"\s+(?:with guide|avec guide|con guida|sans guide|with driver|avec chauffeur|sans chauffeur).*$", r"\s+(?:private|prive|privato|group|en groupe|gruppo).*$", r"\s+(?:breakfast|lunch|dinner|meal|petit dejeuner|dejeuner|diner|colazione|pranzo|cena).*$", r"\s*&\s*(?:arrival|arrivee|depart|departure|arrivo|partenza).*$", r"\s*&\s+.*$", r"\s*,\s+.*$", ] COUNTRY_WORDS = { "vietnam", "viet nam", "laos", "cambodia", "cambodge", "thailand", "thailande", "myanmar", "birmanie", "burma", "indochina", "indochine", } GENERIC_LOCATION_WORDS = { "reserve", "naturelle", "natural", "park", "national", "river", "beach", "bay", "waterfall", "falls", "sanctuary", "village", "floating", "delta", "lake", "mountain", "airport", "station", "pier", "port", "harbor", "hotel", "resort", "homestay", "flottant", "galleggiante", "plage", "spiaggia", "ile", "isola", "baie", } ROUTE_SPLIT_PATTERN = re.compile(r"\s*(?:->|>|/|\||;|\s+-\s+|,\s+|\s*&\s+)\s*") def _strip_accents(value: str) -> str: return "".join( char for char in unicodedata.normalize("NFKD", value or "") if not unicodedata.combining(char) ) def normalize_location_text(value: str) -> str: text = _strip_accents(value) text = unicodedata.normalize("NFKC", text) text = text.lower() text = re.sub(r"[’'`]", "", text) text = re.sub(r"[&/,;:()\[\]{}]", " ", text) text = re.sub(r"[–—−‑‒―<>|/]+", " ", text) text = re.sub(r"[^a-z0-9\s-]", " ", text) text = re.sub(r"[-]+", " ", text) text = re.sub(r"\s+", " ", text).strip() return text def clean_location_fragment(fragment: str) -> str: text = unicodedata.normalize("NFKC", fragment or "").strip() text = re.sub(r"[–—−‑‒―]", "-", text) for pattern in LOCATION_DROP_PREFIXES: text = re.sub(pattern, "", text, flags=re.I).strip() for pattern in LOCATION_DROP_SUFFIXES: text = re.sub(pattern, "", text, flags=re.I).strip() text = re.sub(r"\s+", " ", text).strip(" -") return text def looks_like_location_candidate(fragment: str) -> bool: cleaned = clean_location_fragment(fragment) normalized = normalize_location_text(cleaned) if not normalized or len(normalized) < 3 or normalized.isdigit(): return False if normalized in COUNTRY_WORDS: return False if any(re.search(rf"(? 6: return False if all(token in GENERIC_LOCATION_WORDS or token in COUNTRY_WORDS for token in tokens): return False return True class CityDB: def __init__(self): self.by_name = {} self.alias2name = {} self.alias_entries = [] for c in SEED_CITIES: self.add(c) def add(self, c): self.by_name[c.name] = c keys = [c.name] + c.aliases for k in keys: normalized = normalize_location_text(k) if normalized: self.alias2name[normalized] = c.name self.alias_entries = sorted( self.alias2name.items(), key=lambda item: (len(item[0].split()), len(item[0])), reverse=True, ) def extract_cities(self, text): normalized_text = normalize_location_text(text) if not normalized_text: return [] matches = [] occupied = [] for alias, canonical in self.alias_entries: if len(alias) < 3: continue pattern = re.compile(rf"(?= used_end) for used_start, used_end in occupied): continue occupied.append((start, end)) matches.append((start, canonical)) matches.sort(key=lambda item: item[0]) ordered = [] seen = set() for _, canonical in matches: if canonical not in seen: ordered.append(canonical) seen.add(canonical) return ordered def find_city(self, name, auto_geocode=False): """ Find city by name. If auto_geocode=True and not found, try to geocode from OSM and add to database. """ name_clean = clean_location_fragment(name) name_lower = normalize_location_text(name_clean) # Direct match if name_lower in self.alias2name: return self.by_name[self.alias2name[name_lower]] extracted = self.extract_cities(name_clean) if len(extracted) == 1: return self.by_name.get(extracted[0]) # Auto-geocode if enabled if auto_geocode: print(f"🌐 City '{name_clean}' not in DB, trying Nominatim...") geo = nominatim_geocode(name_clean) if geo: new_city = City( name=geo["name"], lat=geo["lat"], lon=geo["lon"], country=geo["country"], aliases=[] ) self.add(new_city) print(f"✅ Added '{new_city.name}' ({geo['lat']}, {geo['lon']}) to database") return new_city else: print(f"❌ Could not geocode '{name_clean}'") return None def geocode_and_add(self, name): """ Explicitly geocode a city and add to database. Returns the new City object or None. """ geo = nominatim_geocode(name) if geo: new_city = City( name=geo["name"], lat=geo["lat"], lon=geo["lon"], country=geo["country"], aliases=[] ) self.add(new_city) return new_city return None DB = CityDB() def save_label_overrides_to_file(overrides): """ Save label position overrides into assets/cities_master.json. Args: overrides: dict {city_name: {"angle": float, "distance": float}} Returns: tuple: (success: bool, message: str) """ try: _write_cities_master(SEED_CITIES) with open(CITIES_MASTER_FILE, "r", encoding="utf-8") as handle: records = json.load(handle) record_map = { normalize_location_text(record.get("name", "")): record for record in records if isinstance(record, dict) and record.get("name") } changes_made = 0 for city_name, values in overrides.items(): angle = int(round(values.get("angle", 0))) distance = round(values.get("distance", 1.0), 1) lookup_keys = [ normalize_location_text(city_name), normalize_location_text(city_name.replace(" ", "-")), normalize_location_text(city_name.replace("-", " ")), ] target_record = None for lookup_key in lookup_keys: if lookup_key in record_map: target_record = record_map[lookup_key] break if not target_record: continue target_record["label_ang"] = angle target_record["label_dist"] = distance changes_made += 1 matched_city = DB.find_city(city_name) if matched_city: matched_city.label_ang = angle matched_city.label_dist = distance if changes_made > 0: with open(CITIES_MASTER_FILE, "w", encoding="utf-8") as handle: json.dump(records, handle, ensure_ascii=False, indent=2) return True, f"Đã lưu {changes_made} thay đổi vào cities_master.json" return False, "Không tìm thấy city nào trong cities_master.json để cập nhật" except Exception as e: return False, f"Lỗi: {str(e)}" def persist_cities_master(): try: _write_cities_master(SEED_CITIES) return True, "Saved cities_master.json" except Exception as exc: return False, str(exc) def save_city_to_master(name, lat, lon, country="VN", aliases=None): canonical_name = clean_location_fragment(name) if not canonical_name: return False, "Empty city name", None alias_candidates = [] for alias_name in aliases or []: cleaned = clean_location_fragment(alias_name) if cleaned: alias_candidates.append(cleaned) existing = DB.find_city(canonical_name) if existing: existing.lat = float(lat) existing.lon = float(lon) existing.country = country or existing.country normalized_existing_aliases = { normalize_location_text(existing.name), *[normalize_location_text(alias) for alias in existing.aliases], } for alias_name in alias_candidates: normalized_alias = normalize_location_text(alias_name) if normalized_alias and normalized_alias not in normalized_existing_aliases: existing.aliases.append(alias_name) normalized_existing_aliases.add(normalized_alias) DB.add(existing) ok, message = persist_cities_master() return ok, message, existing.name deduped_aliases = [] seen_aliases = {normalize_location_text(canonical_name)} for alias_name in alias_candidates: normalized_alias = normalize_location_text(alias_name) if normalized_alias and normalized_alias not in seen_aliases: deduped_aliases.append(alias_name) seen_aliases.add(normalized_alias) new_city = City( name=canonical_name, lat=lat, lon=lon, country=country or "VN", aliases=deduped_aliases, ) SEED_CITIES.append(new_city) DB.add(new_city) ok, message = persist_cities_master() return ok, message, new_city.name def add_alias_to_city(alias_name, canonical_name): alias_clean = clean_location_fragment(alias_name) if not alias_clean: return False, "Empty alias", None canonical_city = DB.find_city(canonical_name) if not canonical_city: return False, f"City not found: {canonical_name}", None alias_normalized = normalize_location_text(alias_clean) canonical_normalized = normalize_location_text(canonical_city.name) if alias_normalized == canonical_normalized: return False, "Alias matches canonical name", canonical_city.name existing_alias_owner = DB.find_city(alias_clean) if existing_alias_owner and existing_alias_owner.name != canonical_city.name: return False, f"Alias already belongs to {existing_alias_owner.name}", canonical_city.name existing_aliases = { normalize_location_text(canonical_city.name), *[normalize_location_text(alias) for alias in canonical_city.aliases], } if alias_normalized not in existing_aliases: canonical_city.aliases.append(alias_clean) DB.add(canonical_city) ok, message = persist_cities_master() return ok, message, canonical_city.name return True, "Alias already exists", canonical_city.name def canonical_city_name(name): if not name: return None city = DB.find_city(name) if city: return city.name cleaned = clean_location_fragment(name) return cleaned or name def ordered_route_cities(route): ordered = [] for leg in route or []: a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if a_name and (not ordered or ordered[-1] != a_name): ordered.append(a_name) if b_name and (not ordered or ordered[-1] != b_name): ordered.append(b_name) return ordered def default_landmark_cities(base_map_name, hidden_cities=None): base_map_name = normalize_base_map_name(base_map_name) hidden_set = { canonical_city_name(city_name) for city_name in (hidden_cities or []) if canonical_city_name(city_name) } landmarks = set() for city_name in DEFAULT_LANDMARKS_BY_BASE_MAP.get(base_map_name, []): canonical = canonical_city_name(city_name) if canonical and canonical not in hidden_set: landmarks.add(canonical) return landmarks def compute_visible_cities(route, visibility_mode="all", hidden_cities=None, arrival_city=None, departure_city=None, manual_icons=None, label_overrides=None): ordered = ordered_route_cities(route) all_cities = set(ordered) if not all_cities: return set() visibility_mode = (visibility_mode or "all").lower() hidden_set = { canonical_city_name(city_name) for city_name in (hidden_cities or []) if canonical_city_name(city_name) } always_visible = { canonical_city_name(arrival_city), canonical_city_name(departure_city), } protected = set(always_visible) protected.update( canonical_city_name(icon.get("city")) for icon in (manual_icons or []) if icon.get("city") ) protected.update( canonical_city_name(city_name) for city_name in (label_overrides or {}).keys() ) protected.discard(None) if visibility_mode == "custom": return {city for city in all_cities if city not in hidden_set} | {city for city in always_visible if city} if visibility_mode != "major": return all_cities visible = set() visible.add(ordered[0]) visible.add(ordered[-1]) visible.update(protected) city_degree = {} ordered_counts = {} for city_name in ordered: ordered_counts[city_name] = ordered_counts.get(city_name, 0) + 1 for leg in route or []: a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if a_name: city_degree[a_name] = city_degree.get(a_name, 0) + 1 if b_name: city_degree[b_name] = city_degree.get(b_name, 0) + 1 if leg.get("mode") == "plane": if a_name: visible.add(a_name) if b_name: visible.add(b_name) for idx, city_name in enumerate(ordered[1:-1], start=1): prev_leg = route[idx - 1] if idx - 1 < len(route) else None next_leg = route[idx] if idx < len(route) else None prev_mode = prev_leg.get("mode") if prev_leg else None next_mode = next_leg.get("mode") if next_leg else None if prev_mode != next_mode: visible.add(city_name) continue if city_degree.get(city_name, 0) > 2: visible.add(city_name) continue if ordered_counts.get(city_name, 0) > 1: visible.add(city_name) return visible & all_cities MINOR_STOP_HINTS = { "beach", "bay", "cave", "caves", "falls", "fall", "island", "islands", "park", "reserve", "village", "floating", "temple", "pagoda", "pier", "port", "harbor", "airport", "station", "lake", "mountain", "forest", "plage", "baie", "ile", "iles", "flottant", "cascade", "grotte", "isola", "spiaggia", "parc", "reserve", "naturelle", } def visibility_protected_cities(arrival_city=None, departure_city=None, manual_icons=None, label_overrides=None): protected = set() for city_name in [arrival_city, departure_city]: canonical = canonical_city_name(city_name) if canonical: protected.add(canonical) for icon in manual_icons or []: canonical = canonical_city_name(icon.get("city")) if canonical: protected.add(canonical) for city_name in (label_overrides or {}).keys(): canonical = canonical_city_name(city_name) if canonical: protected.add(canonical) return protected def city_visibility_score(city_name, route, ordered_cities, protected_cities): if city_name in protected_cities: return 100 score = 0 if ordered_cities: if city_name == ordered_cities[0]: score += 40 if city_name == ordered_cities[-1]: score += 40 degree = 0 repeated = 0 plane_touch = False mode_change = False ordered_counts = {} for name in ordered_cities: ordered_counts[name] = ordered_counts.get(name, 0) + 1 repeated = ordered_counts.get(city_name, 0) for idx, leg in enumerate(route or []): a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if city_name in {a_name, b_name}: degree += 1 if leg.get("mode") == "plane": plane_touch = True if idx < len(route) - 1: next_leg = route[idx + 1] pivot = canonical_city_name(leg.get("b")) if pivot == city_name and canonical_city_name(next_leg.get("a")) == city_name: if leg.get("mode") != next_leg.get("mode"): mode_change = True if plane_touch: score += 25 if mode_change: score += 20 if degree > 2: score += 15 if repeated > 1: score += 8 tokens = normalize_location_text(city_name).split() if len(tokens) >= 3: score -= 3 if any(token in MINOR_STOP_HINTS for token in tokens): score -= 12 return score def compact_visible_cities(route, visible_cities, get_xy, base_map_name="Vietnam", arrival_city=None, departure_city=None, manual_icons=None, label_overrides=None): ordered = ordered_route_cities(route) if not ordered: return visible_cities protected = visibility_protected_cities( arrival_city=arrival_city, departure_city=departure_city, manual_icons=manual_icons, label_overrides=label_overrides, ) visible_order = [] for city_name in ordered: if city_name in visible_cities and (not visible_order or visible_order[-1] != city_name): visible_order.append(city_name) if len(visible_order) <= 2: return set(visible_order) cluster_threshold = 95 if base_map_name == "Vietnam" else 80 clusters = [] current_cluster = [visible_order[0]] previous_point = get_xy(visible_order[0]) for city_name in visible_order[1:]: point = get_xy(city_name) if not previous_point or not point: clusters.append(current_cluster) current_cluster = [city_name] previous_point = point continue gap = math.hypot(point[0] - previous_point[0], point[1] - previous_point[1]) if gap <= cluster_threshold: current_cluster.append(city_name) else: clusters.append(current_cluster) current_cluster = [city_name] previous_point = point if current_cluster: clusters.append(current_cluster) compacted = set() for cluster in clusters: if len(cluster) <= 2: compacted.update(cluster) continue keep = {cluster[0], cluster[-1]} keep.update(city for city in cluster if city in protected) base_budget = 0 if len(cluster) <= 3 else 1 if len(cluster) <= 5 else 2 candidate_scores = [] for city_name in cluster[1:-1]: score = city_visibility_score(city_name, route, ordered, protected) candidate_scores.append((score, city_name)) if score >= 25: keep.add(city_name) remaining_budget = max(0, base_budget - len([city for city in keep if city in cluster[1:-1]])) for _, city_name in sorted(candidate_scores, key=lambda item: item[0], reverse=True): if remaining_budget <= 0: break if city_name in keep: continue keep.add(city_name) remaining_budget -= 1 compacted.update(keep) return compacted def extract_ordered_locations(text, include_unknown=True): """ Extract ordered location candidates from a route fragment. Returns canonical DB city names when matched, otherwise keeps a cleaned unknown candidate only if it still looks like a location. """ if not text: return [] fragments = ROUTE_SPLIT_PATTERN.split(unicodedata.normalize("NFKC", text)) ordered = [] seen = set() for fragment in fragments: cleaned = clean_location_fragment(fragment) if not cleaned: continue matched = DB.extract_cities(cleaned) if matched: for city_name in matched: key = normalize_location_text(city_name) if key not in seen: ordered.append(city_name) seen.add(key) continue if include_unknown and looks_like_location_candidate(cleaned): key = normalize_location_text(cleaned) if key not in seen: ordered.append(cleaned) seen.add(key) return ordered # ========================= # GEOMETRY & AFFINE # ========================= def solve_affine(latlons, xys): A, B = [], [] for (lon, lat), (X, Y) in zip(latlons, xys): A.append([lon, lat, 1, 0, 0, 0]); B.append(X) A.append([0, 0, 0, lon, lat, 1]); B.append(Y) A = np.array(A, dtype=float); B = np.array(B, dtype=float) try: p, *_ = np.linalg.lstsq(A, B, rcond=None) return p.reshape(2, 3) except: return np.eye(2, 3) # Fallback def apply_affine(M, lon, lat): v = np.array([lon, lat, 1.0]) x, y = M @ v return float(x), float(y) def quad_point(p0, p1, p2, t): x = (1-t)**2*p0[0] + 2*(1-t)*t*p1[0] + t**2 * p2[0] y = (1-t)**2*p0[1] + 2*(1-t)*t*p1[1] + t**2 * p2[1] return (x, y) def control_point(p0, p2, bulge=0.25): mx, my = (p0[0]+p2[0])/2, (p0[1]+p2[1])/2 dx, dy = p2[0]-p0[0], p2[1]-p0[1] L = math.hypot(dx,dy) + 1e-6 nx, ny = -dy/L, dx/L return (mx + nx * L * bulge, my + ny * L * bulge) def sample_curve(p0, p1, p2, n=200): return [quad_point(p0,p1,p2,i/(n-1)) for i in range(n)] def radial_point(center, angle_deg, distance): """Calculate a point at a given angle and distance from center""" rad = math.radians(angle_deg) x = center[0] + math.cos(rad) * distance y = center[1] + math.sin(rad) * distance return (x, y) def draw_line(draw, pts, color, width): """Draw a solid line through points""" try: draw.line(pts, fill=color, width=width, joint="curve") except TypeError: draw.line(pts, fill=color, width=width) def draw_dashed(draw, pts, color, width, dash_on=DASH_ON, dash_off=DASH_OFF): """Draw a dashed line through points""" if not pts or len(pts) < 2: return pattern = [dash_on, dash_off] idx = 0 remain = pattern[idx] on_seg = True x0, y0 = pts[0] for k in range(1, len(pts)): x1, y1 = pts[k] seg = math.hypot(x1 - x0, y1 - y0) while seg > 1e-6: step = min(remain, seg) r = step / seg xm, ym = x0 + (x1 - x0) * r, y0 + (y1 - y0) * r if on_seg: draw.line([(x0, y0), (xm, ym)], fill=color, width=width) seg -= step x0, y0 = xm, ym remain -= step if remain <= 1e-6: idx = (idx + 1) % 2 remain = pattern[idx] on_seg = not on_seg x0, y0 = x1, y1 def draw_arrow(draw, p0, pc, p2, size=ARROW_SIZE, color=COLOR_PLANE, t=0.6): """Draw an arrowhead on a Bezier curve at position t""" # Sample points before and after t to get direction p_at = quad_point(p0, pc, p2, t) p_forward = quad_point(p0, pc, p2, min(1.0, t + 0.01)) p_backward = quad_point(p0, pc, p2, max(0.0, t - 0.01)) # Calculate angle from backward to forward point angle = math.atan2(p_forward[1] - p_backward[1], p_forward[0] - p_backward[0]) x, y = p_at s = size # Triangle vertices in local coordinates (pointing right) triangle = [(-0.9*s, -0.6*s), (0, 0), (-0.9*s, 0.6*s)] # Rotate and translate ca, sa = math.cos(angle), math.sin(angle) rotated = [] for px, py in triangle: rx = px * ca - py * sa + x ry = px * sa + py * ca + y rotated.append((rx, ry)) draw.polygon(rotated, fill=color) # ========================= # ICON HELPERS # ========================= # Module-level cache to avoid reloading icons from disk _ICON_CACHE = {} def get_icons(style_pack): """Load icons for the selected style pack (with caching).""" icon_paths = style_pack.get("icons", {}) cache_key = tuple(sorted((name, path or "") for name, path in icon_paths.items())) if cache_key in _ICON_CACHE: return _ICON_CACHE[cache_key] icons = {} for name, path in icon_paths.items(): if path and os.path.exists(path): icons[name] = Image.open(path).convert("RGBA") _ICON_CACHE[cache_key] = icons return icons def paste_icon_on_curve(overlay, icon, p0, pc, p2, t=0.5, size_px=32): """ Pastes an icon on the Bezier curve at t (0..1), rotated tangentially. Improved rotation logic from app.py-v16 """ # Clamp t to safe range t = max(0.02, min(0.98, float(t))) # Calculate point at t mt = 1 - t x = mt**2 * p0[0] + 2 * mt * t * pc[0] + t**2 * p2[0] y = mt**2 * p0[1] + 2 * mt * t * pc[1] + t**2 * p2[1] # Calculate tangent vector for rotation # Use points slightly ahead and behind for better accuracy t_forward = min(0.999, t + 0.01) t_back = max(0.001, t - 0.01) pf_x = (1-t_forward)**2 * p0[0] + 2*(1-t_forward)*t_forward * pc[0] + t_forward**2 * p2[0] pf_y = (1-t_forward)**2 * p0[1] + 2*(1-t_forward)*t_forward * pc[1] + t_forward**2 * p2[1] pb_x = (1-t_back)**2 * p0[0] + 2*(1-t_back)*t_back * pc[0] + t_back**2 * p2[0] pb_y = (1-t_back)**2 * p0[1] + 2*(1-t_back)*t_back * pc[1] + t_back**2 * p2[1] # Tangent vector tx = pf_x - pb_x ty = pf_y - pb_y # Calculate rotation angle angle_rad = math.atan2(ty, tx) angle_deg = math.degrees(angle_rad) # Resize icon size_px = int(max(8, size_px)) icon_resized = icon.resize((size_px, size_px), Image.Resampling.LANCZOS) # Rotate icon # PIL rotation is counter-clockwise, and screen Y increases downward # Negate angle to account for screen coordinates icon_rotated = icon_resized.rotate(-angle_deg, expand=True, resample=Image.BICUBIC) # Paste centered on the curve point w, h = icon_rotated.size px = int(x - w / 2) py = int(y - h / 2) overlay.paste(icon_rotated, (px, py), icon_rotated) def paste_icon_beside_curve(overlay, icon, p0, pc, p2, t=0.5, size_px=32, offset_px=25, preserve_aspect=False): """ Pastes an icon BESIDE the Bezier curve at t, without rotation. Icon is offset perpendicular to the curve direction (to the left of travel direction). This avoids overlapping with city labels that are typically near the curve. """ # Clamp t to safe range t = max(0.02, min(0.98, float(t))) # Calculate point at t mt = 1 - t x = mt**2 * p0[0] + 2 * mt * t * pc[0] + t**2 * p2[0] y = mt**2 * p0[1] + 2 * mt * t * pc[1] + t**2 * p2[1] # Calculate tangent vector t_forward = min(0.999, t + 0.01) t_back = max(0.001, t - 0.01) pf_x = (1-t_forward)**2 * p0[0] + 2*(1-t_forward)*t_forward * pc[0] + t_forward**2 * p2[0] pf_y = (1-t_forward)**2 * p0[1] + 2*(1-t_forward)*t_forward * pc[1] + t_forward**2 * p2[1] pb_x = (1-t_back)**2 * p0[0] + 2*(1-t_back)*t_back * pc[0] + t_back**2 * p2[0] pb_y = (1-t_back)**2 * p0[1] + 2*(1-t_back)*t_back * pc[1] + t_back**2 * p2[1] # Tangent vector tx = pf_x - pb_x ty = pf_y - pb_y # Normalize tangent length = math.sqrt(tx**2 + ty**2) if length > 0: tx /= length ty /= length # Perpendicular vector (rotate 90 degrees CCW) - points to "left" of travel # For screen coords (Y down), this puts icon to the left of the curve perp_x = -ty perp_y = tx # Offset position beside the curve offset_x = x + perp_x * offset_px offset_y = y + perp_y * offset_px # Resize icon (no rotation) size_px = int(max(8, size_px)) if preserve_aspect and icon.width: width_px = size_px height_px = max(1, int(round(width_px * icon.height / icon.width))) icon_resized = icon.resize((width_px, height_px), Image.Resampling.LANCZOS) else: icon_resized = icon.resize((size_px, size_px), Image.Resampling.LANCZOS) # Paste centered on offset position w, h = icon_resized.size px = int(offset_x - w / 2) py = int(offset_y - h / 2) overlay.paste(icon_resized, (px, py), icon_resized) def paste_icon_centered(overlay, icon, center_x, center_y, size_px=32, preserve_aspect=False): size_px = int(max(8, size_px)) if preserve_aspect and icon.width: width_px = size_px height_px = max(1, int(round(width_px * icon.height / icon.width))) icon_resized = icon.resize((width_px, height_px), Image.Resampling.LANCZOS) else: icon_resized = icon.resize((size_px, size_px), Image.Resampling.LANCZOS) w, h = icon_resized.size px = int(center_x - w / 2) py = int(center_y - h / 2) overlay.paste(icon_resized, (px, py), icon_resized) # ========================= # MAIN BUILD FUNCTION # ========================= def get_city_positions(city_names, base_map_name="Vietnam", style="style1", base_map_style=None): """ Get pixel coordinates (x, y) for cities on the DISPLAYED map. Returns dict: {city_name: (x, y)} in BASE image coordinates (NOT scaled by SS). Note: The rendered map uses SS=2, but when displayed in browser via streamlit_image_coordinates, it shows at base size. So we return base coords. """ from PIL import Image # Get map dimensions (base image, before SS scaling) style_pack = resolve_style_pack(style, base_map_name, base_map_style=base_map_style) base_path = style_pack.get("base_map_path") anchor_key = resolve_anchor_preset_name(base_map_name, style_pack=style_pack, base_map_path=base_path) anchors_cfg = ANCHORS_PRESETS.get(anchor_key, ANCHORS_PRESETS.get(base_map_name, ANCHORS_PRESETS.get("Vietnam", {}))) if not os.path.exists(base_path): W, H = 1000, 2000 else: base_img = Image.open(base_path) W, H = base_img.size # Build affine matrix (same logic as build_map) latlons = [] xys = [] for name, (ax, ay) in anchors_cfg.items(): c = DB.find_city(name) if c: latlons.append((c.lon, c.lat)) xys.append((ax*W, ay*H)) if len(latlons) >= 3: M = solve_affine(latlons, xys) else: M = None position_overrides = CITY_POSITION_OVERRIDES.get((anchor_key, style_pack.get("base_map_style")), {}) # Compute positions in BASE image coordinates positions = {} for city_name in city_names: c = DB.find_city(city_name) if c: canonical_name = canonical_city_name(city_name) or city_name if canonical_name in position_overrides: ox, oy = position_overrides[canonical_name] x, y = ox * W, oy * H elif M is not None: x, y = apply_affine(M, c.lon, c.lat) else: x, y = 0.5 * W, 0.5 * H positions[city_name] = (int(x), int(y)) return positions def build_map(route, base_map_name="Vietnam", output_path="map_output.png", arrival_city=None, departure_city=None, language="EN", manual_icons=None, label_overrides=None, custom_anchors=None, style="style1", arrival_offset=None, departure_offset=None, visibility_mode="all", hidden_cities=None, variant=None, variation_seed=None, label_variation_seed=None, base_map_style=None): """ route: list of dicts {a: "CityA", b: "CityB", mode: "car"/"plane", bulge: 0.25, icon_pct: 70} arrival_city: Optional city name for arrival marker departure_city: Optional city name for departure marker language: "FR", "EN", or "VN" for labels manual_icons: list of {city, type, angle, distance} label_overrides: dict {city_name: {text, angle, distance}} custom_anchors: dict {city_name: (x, y)} to override ANCHORS_PRESETS style: "style1" or "style2" - determines colors, icons, routes, and icon scale base_map_style: Optional "style1" or "style2" override for the background image visibility_mode: "all", "major", or "custom" hidden_cities: explicit hidden city names used when visibility_mode="custom" variation_seed: Optional int seed for visual variation (shuffles curves, angles) label_variation_seed: Optional int seed for label-only layout variation """ import random as _random _rng = _random.Random(variation_seed) if variation_seed is not None else None style_pack = resolve_style_pack(style, base_map_name, variant=variant, base_map_style=base_map_style) colors = style_pack["colors"] icon_scale = style_pack.get("icon_scale", 1.0) line_width_scale = style_pack.get("line_width_scale", 1.0) dash_patterns = style_pack.get("dash_patterns", {}) mode_icon_scale = style_pack.get("mode_icon_scale", {}) mode_offset_scale = style_pack.get("mode_offset_scale", {}) # Extract colors from style color_plane = colors.get("plane", COLOR_PLANE) color_car = colors.get("car", COLOR_CAR) color_boat = colors.get("boat", COLOR_BOAT) color_train = colors.get("train", COLOR_TRAIN) marker_color_vn = colors.get("marker_vn", MARKER_COLOR_VN) marker_color_foreign = colors.get("marker_foreign", MARKER_COLOR_FOREIGN) color_arrival_departure = colors.get("arrival_departure", color_plane) def scaled_icon_px(base_size, mode_name): return int(base_size * icon_scale * mode_icon_scale.get(mode_name, 1.0)) def scaled_offset_px(base_offset, mode_name): return int(base_offset * icon_scale * mode_offset_scale.get(mode_name, 1.0)) def scaled_line_width(base_width, mode_name): return max(1, int(base_width * line_width_scale)) def dash_for(kind_name): dash_on, dash_off = dash_patterns.get(kind_name, (DASH_ON, DASH_OFF)) return max(1, int(dash_on * SS)), max(1, int(dash_off * SS)) # Default parameters if manual_icons is None: manual_icons = [] if label_overrides is None: label_overrides = {} if hidden_cities is None: hidden_cities = [] route_endpoint_cities = { canonical_city_name(route[0].get("a")) if route else None, canonical_city_name(route[-1].get("b")) if route else None, canonical_city_name(arrival_city), canonical_city_name(departure_city), } route_endpoint_cities.discard(None) # Resolve anchor preset against the actual base map file selected for this style. anchor_key = resolve_anchor_preset_name(base_map_name, style_pack=style_pack, base_map_path=style_pack.get("base_map_path")) anchors_cfg = ANCHORS_PRESETS.get(anchor_key, ANCHORS_PRESETS.get(base_map_name, ANCHORS_PRESETS.get("Vietnam", {}))) # Apply custom anchors if provided if custom_anchors: anchors_cfg = dict(anchors_cfg) # Make a copy anchors_cfg.update(custom_anchors) base_path = style_pack.get("base_map_path") if not base_path or not os.path.exists(base_path): base_img = Image.new("RGB", (1000, 2000), (240, 240, 220)) else: base_img = Image.open(base_path).convert("RGBA") W, H = base_img.size # Load Icons icons = get_icons(style_pack) def is_style2_speedboat_leg(leg): if style_pack.get("key") != "style2" or leg.get("mode", "car") != "boat": return False a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if not a_name or not b_name: return False return frozenset((a_name, b_name)) in STYLE2_SPEEDBOAT_ROUTE_PAIRS def is_rach_gia_phu_quoc_speedboat_leg(leg): a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) return bool(a_name and b_name and frozenset((a_name, b_name)) == RACH_GIA_PHU_QUOC_PAIR) def prefers_screen_left_car_icon(leg): if leg.get("mode", "car") != "car": return False a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if not a_name or not b_name: return False return {a_name, b_name}.issubset(CENTRAL_VIETNAM_CAR_ICON_LEFT_CITIES) def adjusted_car_offset_px(leg, p0, pc, p2, t, offset_px): if not prefers_screen_left_car_icon(leg): return offset_px t = max(0.02, min(0.98, float(t))) t_forward = min(0.999, t + 0.01) t_back = max(0.001, t - 0.01) pf_x = (1 - t_forward) ** 2 * p0[0] + 2 * (1 - t_forward) * t_forward * pc[0] + t_forward ** 2 * p2[0] pf_y = (1 - t_forward) ** 2 * p0[1] + 2 * (1 - t_forward) * t_forward * pc[1] + t_forward ** 2 * p2[1] pb_x = (1 - t_back) ** 2 * p0[0] + 2 * (1 - t_back) * t_back * pc[0] + t_back ** 2 * p2[0] pb_y = (1 - t_back) ** 2 * p0[1] + 2 * (1 - t_back) * t_back * pc[1] + t_back ** 2 * p2[1] tx = pf_x - pb_x ty = pf_y - pb_y length = math.sqrt(tx ** 2 + ty ** 2) if length <= 0: return -offset_px tx /= length ty /= length perp_x = -ty mt = 1 - t curve_x = mt ** 2 * p0[0] + 2 * mt * t * pc[0] + t ** 2 * p2[0] positive_x = curve_x + perp_x * offset_px negative_x = curve_x - perp_x * offset_px return offset_px if positive_x <= negative_x else -offset_px def adjusted_speedboat_offset_px(leg, p0, pc, p2, t, offset_px): if not is_rach_gia_phu_quoc_speedboat_leg(leg): return offset_px t = max(0.02, min(0.98, float(t))) t_forward = min(0.999, t + 0.01) t_back = max(0.001, t - 0.01) pf_x = (1 - t_forward) ** 2 * p0[0] + 2 * (1 - t_forward) * t_forward * pc[0] + t_forward ** 2 * p2[0] pf_y = (1 - t_forward) ** 2 * p0[1] + 2 * (1 - t_forward) * t_forward * pc[1] + t_forward ** 2 * p2[1] pb_x = (1 - t_back) ** 2 * p0[0] + 2 * (1 - t_back) * t_back * pc[0] + t_back ** 2 * p2[0] pb_y = (1 - t_back) ** 2 * p0[1] + 2 * (1 - t_back) * t_back * pc[1] + t_back ** 2 * p2[1] tx = pf_x - pb_x ty = pf_y - pb_y length = math.sqrt(tx ** 2 + ty ** 2) if length <= 0: return offset_px tx /= length ty /= length perp_y = tx mt = 1 - t curve_y = mt ** 2 * p0[1] + 2 * mt * t * pc[1] + t ** 2 * p2[1] positive_y = curve_y + perp_y * offset_px negative_y = curve_y - perp_y * offset_px return offset_px if positive_y >= negative_y else -offset_px # Calculate Affine Matrix latlons = [] xys = [] for name, (ax, ay) in anchors_cfg.items(): c = DB.find_city(name) if c: latlons.append((c.lon, c.lat)) xys.append((ax*W, ay*H)) if len(latlons) >= 3: M = solve_affine(latlons, xys) else: M = None def get_xy(name): c = DB.find_city(name) if not c: return None canonical_name = canonical_city_name(name) or name position_overrides = CITY_POSITION_OVERRIDES.get((anchor_key, style_pack.get("base_map_style")), {}) if canonical_name in position_overrides: ox, oy = position_overrides[canonical_name] return (ox * W, oy * H) if M is not None: return apply_affine(M, c.lon, c.lat) else: return (0.5 * W, 0.5 * H) # Drawing Surface (Supersampled) SS = SUPERSAMPLE_DEFAULT overlay = Image.new("RGBA", (W*SS, H*SS), (0,0,0,0)) draw = ImageDraw.Draw(overlay) def fixed_car_icon_center(leg, icon_size_px): if leg.get("mode", "car") != "car": return None a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if not a_name or not b_name: return None if frozenset((a_name, b_name)) == NINH_BINH_HA_LONG_CAR_ICON_PAIR: ninh_binh_xy = get_xy("Ninh Binh") if not ninh_binh_xy: return None ninh_x = ninh_binh_xy[0] * SS ninh_y = ninh_binh_xy[1] * SS return ( ninh_x + icon_size_px * 0.18, ninh_y + icon_size_px * 0.65, ) return None def vietnam_train_bulge_candidates(leg, base_bulge): if leg.get("mode", "car") != "train": return None a_name = canonical_city_name(leg.get("a")) b_name = canonical_city_name(leg.get("b")) if not a_name or not b_name: return None pair = frozenset((a_name, b_name)) if pair in INLAND_TRAIN_ROUTE_PAIRS: # Hanoi-Central train should sit just inside Vietnam's coast: # enough inland bow to avoid the sea, but not far toward Laos. magnitude = max(abs(base_bulge), 0.28) sign = 1 if a_name == "Ha Noi" else -1 fixed_bulge = sign * magnitude return [fixed_bulge, fixed_bulge * 0.82, fixed_bulge * 1.08, fixed_bulge * 0.65] if pair in VIETNAM_COASTAL_TRAIN_ROUTE_PAIRS: # Central-South train should stay on Vietnam's side of the route, # near the East Sea, not bow west into Cambodia. magnitude = max(abs(base_bulge), 0.56) sign = -1 if a_name in {"Da Nang", "Hue"} else 1 fixed_bulge = sign * magnitude return [fixed_bulge, fixed_bulge * 0.82, fixed_bulge * 1.08, fixed_bulge * 0.65] return None # Font try: font_path = os.path.join(ASSETS_DIR, "GillSans.ttc") font = ImageFont.truetype(font_path, 19 * SS, index=0) font_semibold = ImageFont.truetype(font_path, 19 * SS, index=4) except: font = ImageFont.load_default() font_semibold = font def font_for_city_label(city_name): return font_semibold if city_name == "Ha Noi" else font def rects_overlap(box1, box2): return not ( box1["right"] < box2["left"] or box1["left"] > box2["right"] or box1["bottom"] < box2["top"] or box1["top"] > box2["bottom"] ) def center_box(center_x, center_y, width_px, height_px=None, padding_px=None): height_px = width_px if height_px is None else height_px padding_px = 15 * SS if padding_px is None else padding_px return { "left": center_x - (width_px / 2) - padding_px, "top": center_y - (height_px / 2) - padding_px, "right": center_x + (width_px / 2) + padding_px, "bottom": center_y + (height_px / 2) + padding_px, } def box_outside_render_canvas(box): return ( box["left"] < 0 or box["top"] < 0 or box["right"] > (W * SS) or box["bottom"] > (H * SS) ) def box_overlap_area_raw(box1, box2): left = max(box1["left"], box2["left"]) right = min(box1["right"], box2["right"]) top = max(box1["top"], box2["top"]) bottom = min(box1["bottom"], box2["bottom"]) return max(0, right - left) * max(0, bottom - top) def curve_pose(p0, pc, p2, t): t = max(0.02, min(0.98, float(t))) x, y = quad_point(p0, pc, p2, t) t_forward = min(0.999, t + 0.01) t_back = max(0.001, t - 0.01) pf_x, pf_y = quad_point(p0, pc, p2, t_forward) pb_x, pb_y = quad_point(p0, pc, p2, t_back) tx = pf_x - pb_x ty = pf_y - pb_y length = math.sqrt(tx ** 2 + ty ** 2) if length <= 0: return x, y, 1.0, 0.0, 0.0, 1.0 tx /= length ty /= length return x, y, tx, ty, -ty, tx def icon_center_on_curve(p0, pc, p2, t, offset_px): curve_x, curve_y, _, _, perp_x, perp_y = curve_pose(p0, pc, p2, t) return curve_x + perp_x * offset_px, curve_y + perp_y * offset_px def icon_size_for_leg(mode, use_speedboat_icon): if use_speedboat_icon: return 50 * SS return scaled_icon_px(48 * SS if mode == "plane" else 40 * SS, mode) def rough_text_size(text, font_obj): try: bbox = draw.textbbox((0, 0), text, font=font_obj, stroke_width=0) return max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1]) except Exception: return max(1, len(text) * 10 * SS), max(1, 19 * SS) def rough_label_box(city_x, city_y, text, angle, distance, font_obj): width, height = rough_text_size(text, font_obj) rad = math.radians(angle) dx = math.cos(rad) dy = math.sin(rad) gap = max(2 * SS, 2) anchor_x = city_x + dx * (distance + gap) anchor_y = city_y + dy * (distance + gap) if dx > 0.35: left = anchor_x elif dx < -0.35: left = anchor_x - width else: left = anchor_x - (width / 2) if dy > 0.45: top = anchor_y elif dy < -0.45: top = anchor_y - height else: top = anchor_y - (height / 2) padding = 4 * SS return { "left": left - padding, "top": top - padding, "right": left + width + padding, "bottom": top + height + padding, } def grid_box_overlap_count(box, occupied_cells, cell_size=25): left = int(box["left"] / cell_size) right = int(box["right"] / cell_size) top = int(box["top"] / cell_size) bottom = int(box["bottom"] / cell_size) overlap = 0 for gx in range(left, right + 1): for gy in range(top, bottom + 1): if (gx, gy) in occupied_cells: overlap += 1 return overlap def route_icon_t_candidates(seed_t, mode, use_speedboat_icon, explicit_icon_pct): seed_t = max(0.08, min(0.92, float(seed_t))) if explicit_icon_pct: return [seed_t] if mode == "plane": raw = [seed_t, 0.50, 0.38, 0.62, 0.28, 0.72] elif use_speedboat_icon: raw = [seed_t, 0.50, 0.42, 0.58] elif mode == "boat": raw = [seed_t, 0.86, 0.74, 0.62, 0.50] else: raw = [seed_t, 0.50, 0.38, 0.62, 0.28, 0.72] candidates = [] for value in raw: clamped = round(max(0.08, min(0.92, float(value))), 3) if clamped not in candidates: candidates.append(clamped) return candidates def route_icon_offset_candidates(leg, mode, use_speedboat_icon, p0, pc, p2, t, base_offset): if mode == "plane": return [0] preferred = base_offset hard_side = False if mode == "car": preferred = adjusted_car_offset_px(leg, p0, pc, p2, t, base_offset) hard_side = prefers_screen_left_car_icon(leg) elif use_speedboat_icon: preferred = adjusted_speedboat_offset_px(leg, p0, pc, p2, t, base_offset) hard_side = is_rach_gia_phu_quoc_speedboat_leg(leg) multipliers = [1.0, 0.68] offsets = [] for multiplier in multipliers: offsets.append(preferred * multiplier) if not hard_side: for multiplier in multipliers: offsets.append(-preferred * multiplier) deduped = [] for offset in offsets: rounded = round(offset, 3) if rounded not in deduped: deduped.append(rounded) return deduped def choose_route_icon_layout(leg, mode, use_speedboat_icon, p0, pc, p2, seed_t, icon_size, previous_route_cells, city_boxes, expected_label_boxes): fixed_center = fixed_car_icon_center(leg, icon_size) if mode == "car" else None if fixed_center: bbox = center_box(fixed_center[0], fixed_center[1], icon_size) return {"t": seed_t, "center": fixed_center, "bbox": bbox, "offset": 0, "fixed": True} base_offset = scaled_offset_px(20 * SS, "boat" if use_speedboat_icon else mode) explicit_icon_pct = "icon_pct" in leg best_layout = None best_score = float("inf") for t in route_icon_t_candidates(seed_t, mode, use_speedboat_icon, explicit_icon_pct): offsets = route_icon_offset_candidates(leg, mode, use_speedboat_icon, p0, pc, p2, t, base_offset) for offset in offsets: if mode == "plane": center_x, center_y = quad_point(p0, pc, p2, t) else: center_x, center_y = icon_center_on_curve(p0, pc, p2, t, offset) bbox = center_box(center_x, center_y, icon_size) score = abs(t - seed_t) * 8.0 if explicit_icon_pct: score -= 1.5 if abs(offset) < abs(base_offset) and mode != "plane": score += 0.35 if box_outside_render_canvas(bbox): score += 120 for existing in icon_positions: if rects_overlap(bbox, existing["bbox"]): score += 60 + min(box_overlap_area_raw(bbox, existing["bbox"]) / max(icon_size * icon_size, 1), 5) * 12 for marker_box in city_boxes: if rects_overlap(bbox, marker_box): score += 42 + min(box_overlap_area_raw(bbox, marker_box) / max(icon_size * icon_size, 1), 5) * 10 for label_box in expected_label_boxes: if rects_overlap(bbox, label_box): score += 12 + min(box_overlap_area_raw(bbox, label_box) / max(icon_size * icon_size, 1), 4) * 4 route_overlap = grid_box_overlap_count(bbox, previous_route_cells, cell_size=25) if route_overlap: score += min(route_overlap, 12) * 2.5 for endpoint_name in (leg.get("a"), leg.get("b")): endpoint_xy = get_xy(endpoint_name) if endpoint_xy: endpoint_x, endpoint_y = endpoint_xy[0] * SS, endpoint_xy[1] * SS distance = math.hypot(center_x - endpoint_x, center_y - endpoint_y) if distance < (icon_size * 0.62): score += ((icon_size * 0.62) - distance) / max(SS, 1) if mode == "plane": score -= 0.5 elif use_speedboat_icon and is_rach_gia_phu_quoc_speedboat_leg(leg): _, curve_y, _, _, _, _ = curve_pose(p0, pc, p2, t) if center_y < curve_y: score += 200 elif mode == "car" and prefers_screen_left_car_icon(leg): preferred = adjusted_car_offset_px(leg, p0, pc, p2, t, base_offset) if (offset > 0) != (preferred > 0): score += 100 if score < best_score: best_score = score best_layout = {"t": t, "center": (center_x, center_y), "bbox": bbox, "offset": offset, "fixed": False} return best_layout # ============================================= # MERGE CONSECUTIVE SEGMENTS FOR ICON PLACEMENT # ============================================= # User insight: "Đi ô tô thì các chặng liền mạch nhau thì chỉ cần dùng 1 ô tô" # Consecutive same-mode segments should show only ONE icon for the entire chain def merge_consecutive_segments(route_legs): """Merge consecutive same-mode segments into chains for icon placement. Returns list of chains, each with: mode, start_city, end_city, middle_leg_index """ if not route_legs: return [] chains = [] current_chain = None for i, leg in enumerate(route_legs): mode = "speedboat" if is_style2_speedboat_leg(leg) else leg.get("mode", "car") # Check if this leg continues the current chain # Never merge plane or special speedboat legs - each gets its own icon. if current_chain and current_chain["mode"] == mode and mode not in {"plane", "speedboat"} and current_chain["end"] == leg["a"]: # Extend current chain current_chain["end"] = leg["b"] current_chain["leg_indices"].append(i) else: # Save previous chain and start new one if current_chain: chains.append(current_chain) current_chain = { "mode": mode, "start": leg["a"], "end": leg["b"], "leg_indices": [i] } # Don't forget the last chain if current_chain: chains.append(current_chain) return chains # Get merged chains for icon placement icon_chains = merge_consecutive_segments(route) # Pre-calculate which leg index gets the icon (middle of each chain) legs_with_icons = set() chain_icon_info = {} # leg_index -> icon_t position for chain in icon_chains: indices = chain["leg_indices"] chain_mode = chain.get("mode", "car") # If ANY leg in this chain has icon_pct = -1, user wants to hide the icon if any(route[idx].get("icon_pct", 50) < 0 for idx in indices): continue if len(indices) == 1: # Single leg: icon at normal position legs_with_icons.add(indices[0]) chain_icon_info[indices[0]] = None # Use leg's default icon_pct else: # Multiple legs: icon at middle leg. Boat icons should stay closer # to the destination side of the representative leg. middle_idx = indices[len(indices) // 2] legs_with_icons.add(middle_idx) chain_icon_info[middle_idx] = 0.86 if chain_mode in {"boat", "speedboat"} else 0.5 # ============================================= # AUTO-CULL ICONS THAT ARE TOO CLOSE TOGETHER # ============================================= # After chain merging, icons on short car segments can still cluster. # Remove icons whose approximate positions are within a minimum distance. # Users can re-enable via Chỉnh icon → Reset (removes the auto-hide). _MIN_ICON_DIST = 0.15 * min(W, H) # 15% of smaller map dimension _kept_icon_positions = [] # list of (x, y) for kept icons _auto_hidden = set() for leg_idx in sorted(legs_with_icons): leg = route[leg_idx] pt_a = get_xy(leg["a"]) pt_b = get_xy(leg["b"]) if not pt_a or not pt_b: continue # Approximate icon position = midpoint of the leg mid_x = (pt_a[0] + pt_b[0]) / 2 mid_y = (pt_a[1] + pt_b[1]) / 2 too_close = False for kx, ky in _kept_icon_positions: if math.hypot(mid_x - kx, mid_y - ky) < _MIN_ICON_DIST: too_close = True break if too_close and "icon_pct" not in leg and leg.get("mode") != "plane" and not is_style2_speedboat_leg(leg): _auto_hidden.add(leg_idx) else: _kept_icon_positions.append((mid_x, mid_y)) legs_with_icons -= _auto_hidden visible_cities = compute_visible_cities( route, visibility_mode=visibility_mode, hidden_cities=hidden_cities, arrival_city=arrival_city, departure_city=departure_city, manual_icons=manual_icons, label_overrides=label_overrides, ) if (visibility_mode or "all").lower() == "major": visible_cities = compact_visible_cities( route, visible_cities, get_xy, base_map_name=base_map_name, arrival_city=arrival_city, departure_city=departure_city, manual_icons=manual_icons, label_overrides=label_overrides, ) visible_cities = set(visible_cities) | default_landmark_cities(base_map_name, hidden_cities=hidden_cities) # ============================================= # MERGE LEGS AROUND HIDDEN CITIES # ============================================= # Only merge legs for explicitly user-hidden cities (custom hidden / Ẩn nhãn). # Major Stops mode hides labels/markers but keeps original route legs intact. hidden_set = set() for h in (hidden_cities or []): c = canonical_city_name(h) if c: hidden_set.add(c) if hidden_set: merged_route = [] for leg in route: # If previous leg's destination is hidden, merge with this leg if merged_route and canonical_city_name(merged_route[-1]["b"]) in hidden_set: merged_route[-1] = {**merged_route[-1], "b": leg["b"]} else: merged_route.append(dict(leg)) route = merged_route city_marker_boxes = [] expected_label_boxes = [] for city_name in sorted(city for city in visible_cities if city): pt = get_xy(city_name) city_obj = DB.find_city(city_name) if not pt: continue city_x, city_y = pt[0] * SS, pt[1] * SS marker_r = (6 if city_obj and city_obj.country == "VN" else 4) * SS marker_box = center_box(city_x, city_y, marker_r * 2, padding_px=8 * SS) marker_box["name"] = city_name city_marker_boxes.append(marker_box) override = label_overrides.get(city_name, {}) display_text = override.get("text", city_name) label_font = font_for_city_label(city_name) if override and ("offset_x" in override or "offset_y" in override): width, height = rough_text_size(display_text, label_font) left = city_x + (override.get("offset_x", 0) * SS) top = city_y + (override.get("offset_y", 0) * SS) - (height / 2) expected_label_boxes.append({ "left": left - 4 * SS, "top": top - 4 * SS, "right": left + width + 4 * SS, "bottom": top + height + 4 * SS, }) continue if override and ("angle" in override or "distance" in override): angle = override.get("angle", 0) distance = 8 * SS * override.get("distance", 1.0) elif city_obj and city_obj.label_ang is not None: angle = city_obj.label_ang distance = 8 * SS * (city_obj.label_dist if city_obj.label_dist else 1.0) else: angle = 0 distance = 8 * SS expected_label_boxes.append(rough_label_box(city_x, city_y, display_text, angle, distance, label_font)) # Draw Legs all_route_cities = set() drawn_route_cells = set() # Track already drawn routes for collision avoidance icon_positions = [] # Track icon positions for label collision for leg_idx, leg in enumerate(route): a_name, b_name = leg["a"], leg["b"] pt_a = get_xy(a_name) pt_b = get_xy(b_name) if not pt_a or not pt_b: continue x0, y0 = pt_a[0]*SS, pt_a[1]*SS x2, y2 = pt_b[0]*SS, pt_b[1]*SS all_route_cities.add(canonical_city_name(a_name)) all_route_cities.add(canonical_city_name(b_name)) mode = leg.get("mode", "car") use_speedboat_icon = is_style2_speedboat_leg(leg) # SMART ROUTE COLLISION AVOIDANCE # Try different bulge values and pick the one with least overlap with existing routes base_bulge = leg.get("bulge", CURVE_BULGE) # Candidate bulge values to try fixed_train_candidates = vietnam_train_bulge_candidates(leg, base_bulge) if mode == "train" else None if fixed_train_candidates: bulge_candidates = fixed_train_candidates elif mode == "plane": bulge_candidates = [base_bulge, -base_bulge, base_bulge * 1.5, -base_bulge * 1.5, base_bulge * 0.5, -base_bulge * 0.5, base_bulge * 2, -base_bulge * 2] else: bulge_candidates = [base_bulge, -base_bulge * 0.5, base_bulge * 0.5, -base_bulge] best_bulge = base_bulge best_overlap = float('inf') for candidate_bulge in bulge_candidates: pc_test = control_point((x0, y0), (x2, y2), candidate_bulge) pts_test = sample_curve((x0, y0), pc_test, (x2, y2), n=30) # Count overlap with already drawn routes overlap_count = 0 for px, py in pts_test: grid_key = (int(px / 25), int(py / 25)) if grid_key in drawn_route_cells: overlap_count += 1 if overlap_count < best_overlap: best_overlap = overlap_count best_bulge = candidate_bulge # Perfect - no overlap if overlap_count == 0: break bulge = best_bulge # Use chain icon position if this is the representative leg if leg_idx in chain_icon_info and chain_icon_info[leg_idx] is not None: icon_t = chain_icon_info[leg_idx] else: default_icon_pct = 50 if (mode == "plane" or use_speedboat_icon) else 94 if mode == "boat" else 50 icon_pct = leg.get("icon_pct", default_icon_pct) icon_t = icon_pct / 100.0 # Variation seed: randomize icon position on route if _rng is not None and "icon_pct" not in leg: icon_t = _rng.uniform(0.25, 0.75) # Curve with chosen bulge pc = control_point((x0, y0), (x2, y2), bulge) pts = sample_curve((x0,y0), pc, (x2,y2), n=150) previous_route_cells = set(drawn_route_cells) # Add this route's points to drawn set for future collision checks for px, py in pts[::5]: # Sample every 5th point drawn_route_cells.add((int(px / 25), int(py / 25))) width = scaled_line_width(2 * SS, mode) # Check if this leg should show an icon (only representative legs of each chain) should_show_icon = leg_idx in legs_with_icons and leg.get("icon_pct", 50) >= 0 selected_icon_layout = None if should_show_icon: icon_size = icon_size_for_leg(mode, use_speedboat_icon) selected_icon_layout = choose_route_icon_layout( leg, mode, use_speedboat_icon, (x0, y0), pc, (x2, y2), icon_t, icon_size, previous_route_cells, city_marker_boxes, expected_label_boxes, ) if selected_icon_layout: icon_t = selected_icon_layout["t"] icon_x, icon_y = selected_icon_layout["center"] icon_positions.append({ 'x': icon_x, 'y': icon_y, 'size': icon_size, 'bbox': selected_icon_layout["bbox"], }) if mode == "plane": plane_dash_on, plane_dash_off = dash_for("plane") draw_dashed(draw, pts, color_plane, width, dash_on=plane_dash_on, dash_off=plane_dash_off) if should_show_icon and "plane" in icons: paste_icon_on_curve(overlay, icons["plane"], (x0,y0), pc, (x2,y2), t=icon_t, size_px=scaled_icon_px(48 * SS, "plane")) elif mode == "train": draw_line(draw, pts, color_train, width) if should_show_icon and "train" in icons: train_offset_px = selected_icon_layout["offset"] if selected_icon_layout else scaled_offset_px(20 * SS, "train") paste_icon_beside_curve(overlay, icons["train"], (x0,y0), pc, (x2,y2), t=icon_t, size_px=icon_size_for_leg(mode, use_speedboat_icon), offset_px=train_offset_px) elif mode == "boat": draw_line(draw, pts, color_boat, width) boat_icon_name = "speedboat" if use_speedboat_icon and "speedboat" in icons else "boat" if should_show_icon and boat_icon_name in icons: boat_icon_size = icon_size_for_leg(mode, use_speedboat_icon) boat_offset_px = selected_icon_layout["offset"] if selected_icon_layout else scaled_offset_px(20 * SS, "boat") paste_icon_beside_curve( overlay, icons[boat_icon_name], (x0, y0), pc, (x2, y2), t=icon_t, size_px=boat_icon_size, offset_px=boat_offset_px, preserve_aspect=boat_icon_name == "speedboat", ) else: # Car draw_line(draw, pts, color_car, width) if should_show_icon and "car" in icons: car_icon_size = icon_size_for_leg(mode, use_speedboat_icon) if selected_icon_layout and selected_icon_layout.get("fixed"): fixed_icon_center = selected_icon_layout["center"] paste_icon_centered(overlay, icons["car"], fixed_icon_center[0], fixed_icon_center[1], size_px=car_icon_size) else: car_offset_px = selected_icon_layout["offset"] if selected_icon_layout else adjusted_car_offset_px( leg, (x0, y0), pc, (x2, y2), icon_t, scaled_offset_px(20 * SS, "car"), ) paste_icon_beside_curve(overlay, icons["car"], (x0,y0), pc, (x2,y2), t=icon_t, size_px=car_icon_size, offset_px=car_offset_px) # base_map.jpg already contains some printed city labels. Avoid drawing those twice. preprinted_city_labels = set() if base_map_name == "Vietnam" and style_pack.get("has_preprinted_labels"): preprinted_city_labels = { "Dong Van", "Meo Vac", "Lao Cai", "Sapa", "Mu Cang Chai", "Nghia Lo", "Duong Lam", "Mai Chau", "Bac Ha", "Ba Be", "Ha Noi", "Ninh Binh", "Hue", "Da Nang", "Hoi An", "Buon Ma Thuot", "Sai Gon", "Rach Gia", "Ben Tre", "Can Tho", "Mui Ne", "Phu Quoc", "Lan Ha", } # Smart Label Positioning with Collision Detection # First, calculate all potential label positions label_data = [] for city_name in sorted(city for city in visible_cities if city): pt = get_xy(city_name) if pt: x, y = pt[0]*SS, pt[1]*SS # PRIORITY ORDER for label position: # 1. UI overrides (label_overrides from session state) # 2. SAVED positions from map_positions.json # 3. Predefined in SEED_CITIES (city_obj.label_ang/label_dist) # 4. Dynamic collision avoidance override = label_overrides.get(city_name, {}) # Positions come from: # 1. UI overrides (label_overrides passed from session) # 2. SEED_CITIES in this file (city_obj.label_ang/label_dist) city_obj = DB.find_city(city_name) # Keep entry/exit labels explicit even on legacy pre-labelled maps. suppress_text = city_name in preprinted_city_labels and not override and city_name not in route_endpoint_cities # Check for simple X/Y offset override (new system) if override and ('offset_x' in override or 'offset_y' in override): # Direct pixel offset - simple! offset_x = override.get('offset_x', 0) * SS offset_y = override.get('offset_y', 0) * SS display_name = override.get('text', city_name) label_data.append({ 'name': city_name, 'display_name': display_name, 'x': x, 'y': y, 'direct_offset': (offset_x, offset_y), # New: direct offset 'suppress_text': False, 'placed': False, 'label_x': None, 'label_y': None, 'bbox': None }) continue # Angle/distance override (from click or other sources) if override and ('angle' in override or 'distance' in override): # Use same base_dist as SEED_CITIES for consistency base_dist = 8 * SS ang = override.get('angle', 0) dist_mult = override.get('distance', 1.0) angles = [ang] distances = [base_dist * dist_mult] use_predefined = True display_name = override.get('text', city_name) elif city_obj and city_obj.label_ang is not None: # Use predefined position from SEED_CITIES base_dist = 8 * SS dist = base_dist * (city_obj.label_dist if city_obj.label_dist else 1.0) angles = [city_obj.label_ang] distances = [dist] use_predefined = True display_name = city_name else: # Dynamic placement - try 8 cardinal directions angles = [0, -45, -90, 45, 90, -135, 180, 135] distances = [8 * SS, 12 * SS, 18 * SS] use_predefined = False display_name = city_name label_data.append({ 'name': city_name, 'display_name': display_name, # For custom text 'x': x, 'y': y, 'suppress_text': suppress_text, 'angles': angles, 'distances': distances, 'use_predefined': use_predefined, 'placed': False, 'label_x': None, 'label_y': None, 'bbox': None }) def measure_label_text(text, font_obj, draw_obj): """Measure label size without text stroke.""" try: bbox = draw_obj.textbbox((0, 0), text, font=font_obj, stroke_width=0) w = bbox[2] - bbox[0] h = bbox[3] - bbox[1] except Exception: try: bbox = font_obj.getbbox(text) w = bbox[2] - bbox[0] h = bbox[3] - bbox[1] except Exception: w = len(text) * 10 * SS h = 19 * SS return max(w, 1), max(h, 1) def resolve_label_box(city_x, city_y, angle, distance, text, font_obj, draw_obj): """ Convert polar label placement into a top-left draw position and bbox. This keeps labels on the correct side of the city instead of always expanding to the right. """ w, h = measure_label_text(text, font_obj, draw_obj) rad = math.radians(angle) dx = math.cos(rad) dy = math.sin(rad) gap = max(2 * SS, 2) anchor_x = city_x + dx * (distance + gap) anchor_y = city_y + dy * (distance + gap) if dx > 0.35: left = anchor_x elif dx < -0.35: left = anchor_x - w else: left = anchor_x - (w / 2) if dy > 0.45: top = anchor_y elif dy < -0.45: top = anchor_y - h else: top = anchor_y - (h / 2) padding = 4 * SS bbox = { 'left': left - padding, 'top': top - padding, 'right': left + w + padding, 'bottom': top + h + padding, } return left, top, bbox def resolve_direct_offset_box(city_x, city_y, offset_x, offset_y, text, font_obj, draw_obj): """ Legacy direct offset mode stores X/Y relative to city center and used left-middle anchoring. Preserve that behavior but draw using top-left coordinates so bbox math stays consistent. """ w, h = measure_label_text(text, font_obj, draw_obj) left = city_x + offset_x top = city_y + offset_y - (h / 2) padding = 4 * SS bbox = { 'left': left - padding, 'top': top - padding, 'right': left + w + padding, 'bottom': top + h + padding, } return left, top, bbox # Check if two bboxes overlap def boxes_overlap(box1, box2): return not (box1['right'] < box2['left'] or box1['left'] > box2['right'] or box1['bottom'] < box2['top'] or box1['top'] > box2['bottom']) def box_outside_canvas(box): return ( box['left'] < 0 or box['top'] < 0 or box['right'] > (W * SS) or box['bottom'] > (H * SS) ) def grid_overlap_score(box, occupied_cells, cell_size=25): left = int(box['left'] / cell_size) right = int(box['right'] / cell_size) top = int(box['top'] / cell_size) bottom = int(box['bottom'] / cell_size) overlap = 0 for gx in range(left, right + 1): for gy in range(top, bottom + 1): if (gx, gy) in occupied_cells: overlap += 1 return overlap preprinted_label_texts = { "Buon Ma Thuot": "Buon Me Thuat", "Phu Quoc": "Ile de Phu Quoc", } base_label_boxes = [] for preprinted_name in preprinted_city_labels: pt = get_xy(preprinted_name) if not pt: continue city_obj = DB.find_city(preprinted_name) angle = city_obj.label_ang if city_obj and city_obj.label_ang is not None else 0 dist_mult = city_obj.label_dist if city_obj and city_obj.label_dist else 1.0 base_distance = 8 * SS * dist_mult _, _, bbox = resolve_label_box( pt[0] * SS, pt[1] * SS, angle, base_distance, preprinted_label_texts.get(preprinted_name, preprinted_name), font, draw, ) base_label_boxes.append({"name": preprinted_name, "bbox": bbox}) LABEL_OVERLAP_TOLERANCE = 0.18 def box_area(box): return max(0, box["right"] - box["left"]) * max(0, box["bottom"] - box["top"]) def box_overlap_area(box1, box2): left = max(box1["left"], box2["left"]) right = min(box1["right"], box2["right"]) top = max(box1["top"], box2["top"]) bottom = min(box1["bottom"], box2["bottom"]) return max(0, right - left) * max(0, bottom - top) def box_overlap_ratio(box1, box2): overlap = box_overlap_area(box1, box2) if overlap <= 0: return 0.0 return overlap / max(1, min(box_area(box1), box_area(box2))) def tolerated_overlap_penalty(box, other_box, base_weight, tolerance=LABEL_OVERLAP_TOLERANCE): ratio = box_overlap_ratio(box, other_box) if ratio <= tolerance: return 0 excess = (ratio - tolerance) / max(1 - tolerance, 1e-6) return base_weight + (base_weight * 4 * excess) def grid_cell_count(box, cell_size=25): left = int(box["left"] / cell_size) right = int(box["right"] / cell_size) top = int(box["top"] / cell_size) bottom = int(box["bottom"] / cell_size) return max(1, (right - left + 1) * (bottom - top + 1)) def collision_score(box, placed_labels, current_label_name=None): score = 0 for other in placed_labels: if other['placed'] and other['bbox'] and boxes_overlap(box, other['bbox']): score += tolerated_overlap_penalty(box, other["bbox"], 12) for other in base_label_boxes: if boxes_overlap(box, other['bbox']): score += tolerated_overlap_penalty(box, other["bbox"], 10) for icon_pos in icon_positions: if boxes_overlap(box, icon_pos['bbox']): score += tolerated_overlap_penalty(box, icon_pos["bbox"], 16) for marker_box in city_marker_boxes: if marker_box.get("name") == current_label_name: continue if boxes_overlap(box, marker_box): score += tolerated_overlap_penalty(box, marker_box, 10, tolerance=0.05) route_overlap = grid_overlap_score(box, drawn_route_cells, cell_size=25) route_ratio = route_overlap / grid_cell_count(box, cell_size=25) if route_ratio > LABEL_OVERLAP_TOLERANCE: score += min(route_overlap, 16) * 3 * ( (route_ratio - LABEL_OVERLAP_TOLERANCE) / max(1 - LABEL_OVERLAP_TOLERANCE, 1e-6) ) if box_outside_canvas(box): score += 10 return score def angle_delta_deg(a, b): delta = (a - b) % 360 if delta > 180: delta -= 360 return abs(delta) def label_variant_unit(city_name, angle=0, distance=0, channel="score"): if label_variation_seed is None: return 0.0 token = f"{label_variation_seed}|{city_name}|{channel}|{round(float(angle), 2)}|{round(float(distance), 2)}" value = 0 for char in token: value = ((value * 131) + ord(char)) % 1000003 return value / 1000003.0 for label in label_data: nearby = 0 for other in label_data: if other is label: continue if math.hypot(label['x'] - other['x'], label['y'] - other['y']) < (90 * SS): nearby += 1 label['crowding'] = nearby # Place fixed/manual labels first, then crowded zones, then longer labels. label_data.sort( key=lambda item: ( 0 if item.get('direct_offset') else 1, -item.get('crowding', 0), -len(item.get('display_name', item['name'])), ) ) # Place labels with collision avoidance for i, label in enumerate(label_data): placed = False if label.get('suppress_text'): label['placed'] = True continue # Direct X/Y offset - simple! offset IS the position relative to city dot label_font = font_for_city_label(label['name']) if label.get('direct_offset'): offset_x, offset_y = label['direct_offset'] lx, ly, bbox = resolve_direct_offset_box( label['x'], label['y'], offset_x, offset_y, label.get('display_name', label['name']), label_font, draw, ) label['label_x'] = lx label['label_y'] = ly label['bbox'] = bbox label['placed'] = True continue candidate_positions = [] display_text = label.get('display_name', label['name']) text_width, _ = measure_label_text(display_text, label_font, draw) text_scale = max(1.0, text_width / max(70 * SS, 1)) crowding_scale = 1.0 + (label.get('crowding', 0) * 0.22) if label.get('use_predefined') and label.get('angles') and label.get('distances'): # Prefer the preset position, but allow fallback candidates when # it overlaps another label by more than the tolerated threshold. base_angle = label['angles'][0] base_distance = label['distances'][0] dynamic_distances = [] for distance in [int(base_distance * 0.75), base_distance, int(base_distance * 1.25)]: if distance not in dynamic_distances: dynamic_distances.append(distance) for extra_distance in [10, 18, 28, 42, 58]: scaled = int(base_distance + extra_distance * SS * text_scale * crowding_scale) if scaled not in dynamic_distances: dynamic_distances.append(scaled) dynamic_angles = [] fallback_angles = [ base_angle, base_angle - 30, base_angle + 30, base_angle - 60, base_angle + 60, base_angle - 90, base_angle + 90, base_angle + 180, 0, -45, -90, 45, 90, -135, 180, 135, ] for angle in fallback_angles: normalized = ((angle + 180) % 360) - 180 if normalized not in dynamic_angles: dynamic_angles.append(normalized) for distance in dynamic_distances: for angle in dynamic_angles: candidate_positions.append((angle, distance)) else: dynamic_distances = list(label.get('distances', [])) # Keep search area TIGHT — overlap is better than jumping far for extra_distance in [6, 18, 26, 34, 46]: scaled = int(extra_distance * SS * text_scale * crowding_scale) if scaled not in dynamic_distances: dynamic_distances.append(scaled) dynamic_angles = [] for angle in (label.get('angles', []) + [-30, 30, -60, 60, -120, 120, 150, -150, 180]): if angle not in dynamic_angles: dynamic_angles.append(angle) for distance in dynamic_distances: for angle in dynamic_angles: candidate_positions.append((angle, distance)) variant_preferred = None if label_variation_seed is not None and candidate_positions: variant_index = int(label_variant_unit( label['name'], label.get('crowding', 0), len(candidate_positions), "candidate", ) * len(candidate_positions)) variant_preferred = candidate_positions[min(variant_index, len(candidate_positions) - 1)] best_candidate = None best_score = float('inf') best_close_candidate = None best_close_score = float('inf') close_distance_limit = max(32 * SS, (32 + min(label.get('crowding', 0), 3) * 10) * SS) for angle, distance in candidate_positions: lx, ly, bbox = resolve_label_box( label['x'], label['y'], angle, distance, display_text, label_font, draw, ) score = collision_score(bbox, label_data[:i], label['name']) distance_penalty = distance / max(28 * SS * max(crowding_scale, 1), 1) score += distance_penalty if label.get('use_predefined') and label.get('angles'): score += angle_delta_deg(angle, label['angles'][0]) / 180.0 if label.get('use_predefined') and label.get('distances'): score += abs(distance - label['distances'][0]) / max(28 * SS, 1) * 0.35 if label.get('crowding', 0) <= 1 and distance > (40 * SS): score += 2.5 if variant_preferred: preferred_angle, preferred_distance = variant_preferred crowding_weight = 0.75 + min(label.get('crowding', 0), 4) * 0.35 score += angle_delta_deg(angle, preferred_angle) / 180.0 * crowding_weight score += abs(distance - preferred_distance) / max(70 * SS, 1) * 0.25 score += label_variant_unit(label['name'], angle, distance, "jitter") * 0.18 if score < best_score: best_score = score best_candidate = (lx, ly, bbox, distance) if distance <= close_distance_limit and score < best_close_score: best_close_score = score best_close_candidate = (lx, ly, bbox, distance) if score == 0: label['label_x'] = lx label['label_y'] = ly label['bbox'] = bbox label['placed'] = True placed = True break if not placed and best_candidate: chosen = best_candidate if best_close_candidate: far_distance = best_candidate[3] if far_distance > close_distance_limit and best_close_score <= (best_score + 4.5): chosen = best_close_candidate label['label_x'], label['label_y'], label['bbox'], _ = chosen label['placed'] = True def draw_star_marker(draw_obj, cx, cy, outer_r, inner_r): points = [] for idx in range(10): angle = math.radians(-90 + idx * 36) radius = outer_r if idx % 2 == 0 else inner_r points.append((cx + math.cos(angle) * radius, cy + math.sin(angle) * radius)) closed = points + [points[0]] draw_obj.line(closed, fill=(255, 255, 255, 230), width=max(1, int(5 * SS)), joint="curve") draw_obj.line(closed, fill=(255, 0, 0, 255), width=max(1, int(3 * SS)), joint="curve") star_icon = None star_icon_path = os.path.join(MAP_STYLE_DIR, "shared", "star_icon.png") if os.path.exists(star_icon_path): try: star_icon = Image.open(star_icon_path).convert("RGBA") except Exception: star_icon = None def paste_star_marker(cx, cy, size_px): if not star_icon: draw_star_marker(draw, cx, cy, outer_r=15 * SS, inner_r=6 * SS) return icon_resized = star_icon.resize((size_px, size_px), Image.Resampling.LANCZOS) px = int(cx - size_px / 2) py = int(cy - size_px / 2) overlay.paste(icon_resized, (px, py), icon_resized) def paste_city_decoration(label, mode_name, size_px, dx=45, dy=-42): icon = icons.get(mode_name) if not icon: return icon_resized = icon.resize((size_px, size_px), Image.Resampling.LANCZOS) center_x = label["x"] + dx * SS center_y = label["y"] + dy * SS px = int(center_x - size_px / 2) py = int(center_y - size_px / 2) overlay.paste(icon_resized, (px, py), icon_resized) star_marker_cities = {"Ha Noi"} city_decorations = { "Ha Long": {"mode": "boat", "size": scaled_icon_px(58 * SS, "boat"), "dx": 45, "dy": -42}, "Lan Ha": {"mode": "boat", "size": scaled_icon_px(58 * SS, "boat"), "dx": 45, "dy": -42}, } # Now draw all markers and labels for label in label_data: x, y = label['x'], label['y'] # Draw marker city_obj = DB.find_city(label['name']) marker_fill = marker_color_vn if city_obj and city_obj.country == "VN" else marker_color_foreign if label["name"] in star_marker_cities: paste_star_marker(x, y, size_px=30 * SS) else: r = (6 if city_obj and city_obj.country == "VN" else 4) * SS draw.ellipse((x-r, y-r, x+r, y+r), fill=marker_fill, outline="white", width=2) # Draw label at calculated position if not label.get('suppress_text') and label['label_x'] and label['label_y']: # Use display_name which already includes any custom text overrides display_text = label.get('display_name', label['name']) text_fill = (0xCC, 0x00, 0x00, 255) if label["name"] == "Ha Noi" else "black" bbox = label.get('bbox') if bbox: connect_x = min(max(x, bbox['left']), bbox['right']) connect_y = min(max(y, bbox['top']), bbox['bottom']) dx = connect_x - x dy = connect_y - y if math.hypot(dx, dy) > (18 * SS): draw.line( [(x, y), (connect_x, connect_y)], fill=(90, 90, 90, 180), width=max(1, int(1.2 * SS)) ) draw.text( (label['label_x'], label['label_y']), display_text, font=font_for_city_label(label['name']), fill=text_fill ) decoration = city_decorations.get(label["name"]) if decoration and not label.get("suppress_text"): paste_city_decoration( label, decoration["mode"], decoration["size"], dx=decoration.get("dx", 45), dy=decoration.get("dy", -42), ) # Draw Manual Icons (free-floating, not on routes) for manual_icon in manual_icons: city_name = manual_icon.get('city') icon_type = manual_icon.get('type', 'car') angle_deg = manual_icon.get('angle', 0) distance_px = manual_icon.get('distance', 30) pt = get_xy(city_name) if pt and icon_type in icons: # Calculate position based on angle and distance from city rad = math.radians(angle_deg) x = (pt[0] + math.cos(rad) * distance_px) * SS y = (pt[1] + math.sin(rad) * distance_px) * SS # Paste icon at calculated position icon_img = icons[icon_type] size_px = scaled_icon_px(40 * SS, icon_type) icon_resized = icon_img.resize((size_px, size_px), Image.Resampling.LANCZOS) px = int(x - size_px / 2) py = int(y - size_px / 2) overlay.paste(icon_resized, (px, py), icon_resized) # Draw Arrival and Departure Markers with SMART ANGLE SELECTION # Build occupied regions from routes and cities for collision detection occupied_points = set() # Add route points to occupied set - with expanded radius to avoid overlap for leg in route: pt_a = get_xy(leg["a"]) pt_b = get_xy(leg["b"]) if pt_a and pt_b: x0, y0 = pt_a[0] * SS, pt_a[1] * SS x2, y2 = pt_b[0] * SS, pt_b[1] * SS pc = control_point((x0, y0), (x2, y2), leg.get("bulge", CURVE_BULGE)) pts = sample_curve((x0, y0), pc, (x2, y2), n=80) for px, py in pts: # Add to grid with expanded radius (5 cells around each point) gx, gy = int(px / 20), int(py / 20) for dx in range(-3, 4): for dy in range(-3, 4): occupied_points.add((gx + dx, gy + dy)) # Add city marker positions to occupied set for city_name in sorted(city for city in visible_cities if city): pt = get_xy(city_name) if pt: x, y = pt[0] * SS, pt[1] * SS for dx in range(-2, 3): for dy in range(-2, 3): occupied_points.add((int(x / 20) + dx, int(y / 20) + dy)) # Add placed label boxes so arrival/departure markers do not cross text for label in label_data: bbox = label.get("bbox") if not bbox: continue left = int(bbox["left"] / 20) right = int(bbox["right"] / 20) top = int(bbox["top"] / 20) bottom = int(bbox["bottom"] / 20) for gx in range(left, right + 1): for gy in range(top, bottom + 1): occupied_points.add((gx, gy)) for icon_pos in icon_positions: bbox = icon_pos.get("bbox") if not bbox: continue left = int(bbox["left"] / 20) right = int(bbox["right"] / 20) top = int(bbox["top"] / 20) bottom = int(bbox["bottom"] / 20) for gx in range(left, right + 1): for gy in range(top, bottom + 1): occupied_points.add((gx, gy)) marker_icon_size = scaled_icon_px(48 * SS, "plane") if "plane" in icons else 0 marker_icon_t_from_city = 0.68 def marker_curve_geometry(p_city, p_edge, marker_type): if marker_type == "arrival": p_start, p_end = p_edge, p_city icon_t = 1.0 - marker_icon_t_from_city else: p_start, p_end = p_city, p_edge icon_t = marker_icon_t_from_city pc = control_point(p_start, p_end, CURVE_BULGE) return p_start, pc, p_end, icon_t def curve_icon_bbox(p_start, pc, p_end, icon_t, size_px, padding_px): x, y = quad_point(p_start, pc, p_end, icon_t) half = size_px / 2 return { "left": x - half - padding_px, "top": y - half - padding_px, "right": x + half + padding_px, "bottom": y + half + padding_px, } def mark_box_occupied(box, occupied, cell_size=20): left = int(box["left"] / cell_size) right = int(box["right"] / cell_size) top = int(box["top"] / cell_size) bottom = int(box["bottom"] / cell_size) for gx in range(left, right + 1): for gy in range(top, bottom + 1): occupied.add((gx, gy)) def score_curve_path(p_city, angle, radius, occupied, marker_type): """Calculate how many occupied grid cells this angle would cross.""" p_edge = radial_point(p_city, angle, radius) p_start, pc, p_end, icon_t = marker_curve_geometry(p_city, p_edge, marker_type) pts = sample_curve(p_start, pc, p_end, n=30) overlap_count = 0 for px, py in pts: grid_key = (int(px / 20), int(py / 20)) if grid_key in occupied: overlap_count += 1 return overlap_count, p_edge, p_start, pc, p_end, icon_t def point_outside_canvas(point): return point[0] < 0 or point[1] < 0 or point[0] > (W * SS) or point[1] > (H * SS) def score_marker_candidate(p_city, angle, radius, label_text, occupied, preference_rank, marker_type): curve_overlap, p_edge, p_start, pc, p_end, icon_t = score_curve_path( p_city, angle, radius, occupied, marker_type, ) lx, ly, bbox = resolve_label_box( p_edge[0], p_edge[1], angle, 6 * SS, label_text, font, draw, ) label_overlap = grid_overlap_score(bbox, occupied, cell_size=20) score = curve_overlap score += label_overlap * 2.6 score += preference_rank * 0.75 score += max(radius - (0.18 * min(W, H) * SS), 0) / max(24 * SS, 1) icon_bbox = None if marker_icon_size: icon_bbox = curve_icon_bbox(p_start, pc, p_end, icon_t, marker_icon_size, 12 * SS) icon_overlap = grid_overlap_score(icon_bbox, occupied, cell_size=20) score += icon_overlap * 6.0 if boxes_overlap(icon_bbox, bbox): score += 16 for other in base_label_boxes: if boxes_overlap(icon_bbox, other["bbox"]): score += 20 for placed_label in label_data: placed_bbox = placed_label.get("bbox") if placed_bbox and boxes_overlap(icon_bbox, placed_bbox): score += 22 for icon_pos in icon_positions: if boxes_overlap(icon_bbox, icon_pos["bbox"]): score += 34 if box_outside_canvas(icon_bbox): score += 18 if point_outside_canvas(p_edge): score += 25 if box_outside_canvas(bbox): score += 18 margin = min( p_edge[0], p_edge[1], (W * SS) - p_edge[0], (H * SS) - p_edge[1], ) if margin < (28 * SS): score += ((28 * SS) - margin) / max(6 * SS, 1) return score, p_edge, lx, ly, bbox, p_start, pc, p_end, icon_t, icon_bbox def find_best_marker_layout(p_city, base_radius, preferred_angles, label_text, marker_type): candidate_angles = [] seen_angles = set() for angle in preferred_angles + [-180, -157, -135, -112, -90, -67, -45, -22, 0, 22, 45, 67, 90, 112, 135, 157, 180]: normalized = ((angle + 180) % 360) - 180 if normalized not in seen_angles: candidate_angles.append(normalized) seen_angles.add(normalized) best = None radius_steps = [1.0, 1.15, 1.3, 1.48] for radius_mult in radius_steps: radius = base_radius * radius_mult for rank, angle in enumerate(candidate_angles): score, p_edge, lx, ly, bbox, p_start, pc, p_end, icon_t, icon_bbox = score_marker_candidate( p_city, angle, radius, label_text, occupied_points, rank, marker_type, ) if best is None or score < best["score"]: best = { "score": score, "angle": angle, "radius": radius, "p_edge": p_edge, "label_x": lx, "label_y": ly, "label_bbox": bbox, "p_start": p_start, "pc": pc, "p_end": p_end, "icon_t": icon_t, "icon_bbox": icon_bbox, } if score <= 0.5: return best return best for marker_type, city_name in [("arrival", arrival_city), ("departure", departure_city)]: if not city_name or city_name == "None": continue pt = get_xy(city_name) if not pt: continue # City center point (scaled) cx, cy = pt[0] * SS, pt[1] * SS p_city = (cx, cy) # Calculate radial distance (18% of smaller map dimension) mWH = min(W, H) radius = 0.18 * mWH * SS # Check for custom offset from user click custom_offset = arrival_offset if marker_type == "arrival" else departure_offset label_text = TRANSLATIONS.get(language, TRANSLATIONS["EN"]).get(marker_type, marker_type.title()) if custom_offset: # User clicked a custom position - use it directly p_edge = (custom_offset["x"] * SS, custom_offset["y"] * SS) p_start, pc, p_end, icon_t = marker_curve_geometry(p_city, p_edge, marker_type) icon_bbox = curve_icon_bbox(p_start, pc, p_end, icon_t, marker_icon_size, 12 * SS) if marker_icon_size else None label_x, label_y, label_bbox = resolve_label_box( p_edge[0], p_edge[1], -90 if marker_type == "arrival" else 45, 6 * SS, label_text, font, draw, ) else: if marker_type == "arrival": # Arrival: prefer upper-left quadrant preferred = [-135, -112, -157, -90, -45, -22, 180, 135, 157, 112, 90, 45, 0] else: # Departure: prefer lower-right or lower-left before upper quadrants preferred = [45, 67, 22, 90, 112, 0, 135, -45, 157, -67, 180, -90, -135] if _rng is not None: _rng.shuffle(preferred) best_layout = find_best_marker_layout(p_city, radius, preferred, label_text, marker_type) p_edge = best_layout["p_edge"] label_x = best_layout["label_x"] label_y = best_layout["label_y"] label_bbox = best_layout["label_bbox"] p_start = best_layout["p_start"] pc = best_layout["pc"] p_end = best_layout["p_end"] icon_t = best_layout["icon_t"] icon_bbox = best_layout["icon_bbox"] pts = sample_curve(p_start, pc, p_end, n=220) ad_dash_on, ad_dash_off = dash_for("arrival_departure") draw_dashed(draw, pts, color_arrival_departure, scaled_line_width(2 * SS, "plane"), dash_on=ad_dash_on, dash_off=ad_dash_off) if "plane" in icons: paste_icon_on_curve(overlay, icons["plane"], p_start, pc, p_end, t=icon_t, size_px=marker_icon_size) draw.text( (label_x, label_y), label_text, font=font, fill=color_arrival_departure ) # Register this marker's position so the next one avoids it for dx in range(-2, 3): for dy in range(-2, 3): occupied_points.add((int(p_edge[0] / 20) + dx, int(p_edge[1] / 20) + dy)) mark_box_occupied(label_bbox, occupied_points) if icon_bbox: mark_box_occupied(icon_bbox, occupied_points) # Downsample and Composite overlay = overlay.resize((W, H), Image.Resampling.LANCZOS) final = Image.alpha_composite(base_img, overlay) final.convert("RGB").save(output_path) return output_path