| import os
|
| import math
|
| import json
|
| import re
|
| import unicodedata
|
| import numpy as np
|
| from PIL import Image, ImageDraw, ImageFilter, ImageFont |
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
| 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), |
| }, |
| "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"): { |
| |
| |
| |
| "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 = []
|
|
|
| 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
|
|
|
| for f in sorted(_glob.glob(os.path.join(style_dir, f"base_map_{slug}_blank_*.*"))):
|
| fname = os.path.basename(f)
|
|
|
| stem = os.path.splitext(fname)[0]
|
| suffix = stem.replace(f"base_map_{slug}_blank_", "")
|
| 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(" ", "_") |
|
|
|
|
| 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:
|
|
|
| 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, |
| } |
|
|
|
|
| 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"): |
| |
| 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_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
|
|
|
|
|
| TRANSLATIONS = {
|
| "FR": {"arrival": "Arrivée", "departure": "Départ"},
|
| "EN": {"arrival": "Arrival", "departure": "Departure"},
|
| "IT": {"arrival": "Arrivo", "departure": "Partenza"}
|
| }
|
|
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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),
|
|
|
|
|
| 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_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)
|
|
|
| country = "VN"
|
| 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"(?<![a-z0-9]){re.escape(phrase)}(?![a-z0-9])", normalized) for phrase in LOCATION_SKIP_PHRASES):
|
| return False
|
| tokens = normalized.split()
|
| if len(tokens) > 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"(?<![a-z0-9]){re.escape(alias)}(?![a-z0-9])")
|
| for match in pattern.finditer(normalized_text):
|
| start, end = match.span()
|
| if any(not (end <= used_start or start >= 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)
|
|
|
|
|
| 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])
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
| 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"""
|
|
|
| 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))
|
|
|
|
|
| angle = math.atan2(p_forward[1] - p_backward[1], p_forward[0] - p_backward[0])
|
|
|
| x, y = p_at
|
| s = size
|
|
|
|
|
| triangle = [(-0.9*s, -0.6*s), (0, 0), (-0.9*s, 0.6*s)]
|
|
|
|
|
| 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_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
|
| """
|
|
|
| t = max(0.02, min(0.98, float(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]
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| angle_rad = math.atan2(ty, tx)
|
| angle_deg = math.degrees(angle_rad)
|
|
|
|
|
| size_px = int(max(8, size_px))
|
| icon_resized = icon.resize((size_px, size_px), Image.Resampling.LANCZOS)
|
|
|
|
|
|
|
|
|
| icon_rotated = icon_resized.rotate(-angle_deg, expand=True, resample=Image.BICUBIC)
|
|
|
|
|
| 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.
|
| """
|
|
|
| t = max(0.02, min(0.98, float(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]
|
|
|
|
|
| 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:
|
| tx /= length
|
| ty /= length
|
|
|
|
|
|
|
| perp_x = -ty
|
| perp_y = tx
|
|
|
|
|
| offset_x = x + perp_x * offset_px
|
| offset_y = y + perp_y * offset_px
|
|
|
| |
| 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(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) |
|
|
| |
| |
| |
| 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
|
|
|
|
|
| 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
|
|
|
|
|
| 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")), {}) |
| |
| |
| 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", {})
|
|
|
|
|
| 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))
|
|
|
|
|
| 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) |
|
|
| |
| 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", {}))) |
|
|
|
|
| if custom_anchors:
|
| anchors_cfg = dict(anchors_cfg)
|
| 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 |
| |
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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: |
| |
| |
| 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: |
| |
| |
| 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 |
| |
| |
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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") |
| |
| |
| |
| if current_chain and current_chain["mode"] == mode and mode not in {"plane", "speedboat"} and current_chain["end"] == leg["a"]: |
|
|
| current_chain["end"] = leg["b"]
|
| current_chain["leg_indices"].append(i)
|
| else:
|
|
|
| if current_chain:
|
| chains.append(current_chain)
|
| current_chain = {
|
| "mode": mode,
|
| "start": leg["a"],
|
| "end": leg["b"],
|
| "leg_indices": [i]
|
| }
|
|
|
|
|
| if current_chain:
|
| chains.append(current_chain)
|
|
|
| return chains
|
|
|
|
|
| icon_chains = merge_consecutive_segments(route)
|
|
|
|
|
| legs_with_icons = set()
|
| chain_icon_info = {}
|
|
|
| for chain in icon_chains:
|
| indices = chain["leg_indices"]
|
| chain_mode = chain.get("mode", "car")
|
|
|
|
|
| if any(route[idx].get("icon_pct", 50) < 0 for idx in indices):
|
| continue
|
|
|
| if len(indices) == 1:
|
|
|
| legs_with_icons.add(indices[0])
|
| chain_icon_info[indices[0]] = None
|
| else:
|
| |
| |
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _MIN_ICON_DIST = 0.15 * min(W, H)
|
| _kept_icon_positions = []
|
| _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
|
|
|
| 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) |
|
|
| |
| |
|
|
|
|
|
|
| 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 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)) |
|
|
| |
| all_route_cities = set() |
| drawn_route_cells = set()
|
| icon_positions = []
|
|
|
| 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) |
|
|
| |
| |
| base_bulge = leg.get("bulge", CURVE_BULGE) |
| |
| |
| 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)
|
|
|
|
|
| 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
|
|
|
|
|
| if overlap_count == 0:
|
| break
|
|
|
| bulge = best_bulge
|
|
|
|
|
| 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 |
|
|
| if _rng is not None and "icon_pct" not in leg:
|
| icon_t = _rng.uniform(0.25, 0.75)
|
|
|
| |
| 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) |
| |
| |
| for px, py in pts[::5]: |
| drawn_route_cells.add((int(px / 25), int(py / 25))) |
|
|
| width = scaled_line_width(2 * SS, mode)
|
|
|
| |
| 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: |
| 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) |
|
|
|
|
| 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",
|
| }
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| override = label_overrides.get(city_name, {})
|
|
|
|
|
|
|
|
|
| |
| city_obj = DB.find_city(city_name) |
| |
| suppress_text = city_name in preprinted_city_labels and not override and city_name not in route_endpoint_cities |
|
|
|
|
| if override and ('offset_x' in override or 'offset_y' in override):
|
|
|
| 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),
|
| 'suppress_text': False,
|
| 'placed': False,
|
| 'label_x': None,
|
| 'label_y': None,
|
| 'bbox': None
|
| })
|
| continue
|
|
|
|
|
| if override and ('angle' in override or 'distance' in override):
|
|
|
| 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:
|
|
|
| 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:
|
|
|
| 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,
|
| '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
|
|
|
|
|
| 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
|
|
|
|
|
| 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'])),
|
| )
|
| )
|
|
|
|
|
| for i, label in enumerate(label_data):
|
| placed = False
|
|
|
| if label.get('suppress_text'):
|
| label['placed'] = True
|
| continue
|
|
|
| |
| 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'): |
| |
| |
| 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', [])) |
| |
| 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}, |
| } |
|
|
| |
| for label in label_data: |
| x, y = label['x'], label['y'] |
| |
| |
| 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) |
|
|
|
|
| if not label.get('suppress_text') and label['label_x'] and label['label_y']:
|
| |
| 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), |
| ) |
|
|
|
|
| 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:
|
|
|
| rad = math.radians(angle_deg)
|
| x = (pt[0] + math.cos(rad) * distance_px) * SS
|
| y = (pt[1] + math.sin(rad) * distance_px) * SS
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| occupied_points = set()
|
|
|
|
|
| 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:
|
|
|
| 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))
|
|
|
|
|
| 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))
|
|
|
| |
| 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
|
|
|
|
|
| cx, cy = pt[0] * SS, pt[1] * SS
|
| p_city = (cx, cy)
|
|
|
|
|
| mWH = min(W, H)
|
| radius = 0.18 * mWH * SS
|
|
|
| |
| 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: |
| |
| 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":
|
|
|
| preferred = [-135, -112, -157, -90, -45, -22, 180, 135, 157, 112, 90, 45, 0]
|
| else:
|
|
|
| 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 |
| ) |
|
|
| |
| 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) |
| |
|
|
| overlay = overlay.resize((W, H), Image.Resampling.LANCZOS)
|
| final = Image.alpha_composite(base_img, overlay)
|
|
|
| final.convert("RGB").save(output_path)
|
| return output_path
|
|
|