Resum curt (meta‑description):
Projecte en Python (Pygame) que dibuixa un mapa mundial estil WarGames, mostra la posició de l’ISS en temps real i incorpora botons tàctils autoajustables (128×64 px) per controlar capes i opcions. Compatible amb shapefiles de Natural Earth (1:50m) per a contorns reals.


1) Què fa aquest projecte?

  • Renderitza un mapa equirectangular amb l’estètica verda/CRT de WarGames (scanlines, vinyeta, neó).
  • Dibuixa graticula (paral·lels/meridians) amb etiquetes de graus.
  • Carrega contorns reals dels continents des d’un shapefile de Natural Earth 1:50m (ne_50m_land.shp).
  • Consulta cada 5 s la posició de l’Estació Espacial Internacional (ISS) i pinta:
    • Un blip amb glow tipus radar.
    • Un marcador triangular orientat amb el rumb entre les dues últimes posicions.
    • La traça del recorregut, evitant el salt en el meridià ±180°.
  • Incorpora una barra de botons tàctils (128×64 px, multi‑fila, autoajustable) per activar/aturar: Grid, Cities, Scanlines, Outline img, Land outline, ISS trail, Holes, Refresh, Screenshot, Fullscreen i Quit.

2) Requisits i instal·lació

  • Python 3.8+ (provats en Ubuntu 20.04).
  • Llibreries:
pip install pygame requests pyshp
  • Mostrar más líneas
  • Fitxers del shapefile de Natural Earth (al mateix directori que el script):
    • ne_50m_land.shp
    • ne_50m_land.shx
    • ne_50m_land.dbf
    • (opcional) ne_50m_land.prj

Si no hi són, el programa cau al mode low‑poly integrat per seguir funcionant.


3) Arquitectura general del codi

El fitxer principal (per exemple war_games.py) organitza el projecte en 5 blocs:

  1. Estètica i utilitats: projecció (lon/lat → x/y), scanlines, vinyeta, text amb ombra i colors “neó”.
  2. Dades geoespacials: càrrega del shapefile amb PyShp, simplificació RDP i segmentació al dateline.
  3. Grid i capes: dibuix de paral·lels i meridians; “ciutats” estil blip.
  4. ISS: consulta a l’API open-notify, càlcul de rumb i traça.
  5. UI tàctil: classe Button, distribució automàtica (128×64) i gestió d’esdeveniments.

A continuació, expliquem cada peça.


ef lonlat_to_xy(lon, lat, rect):
    x = rect.left + (lon + 180.0) / 360.0 * rect.width
    y = rect.top  + (90.0 - lat) / 180.0  * rect.height
    return int(x), int(y)

4) Projecció equirectangular i “look” WarGames

Conversió lon/lat → píxels

PythonMostrar más líneas

  • Entrada: longitud (−180..180), latitud (−90..90) i el rectangle interior del mapa.
  • Sortida: coordenades de pantalla (x, y) sobre una projecció equirectangular.

Efecte CRT (scanlines + vinyeta)

  • Scanlines: línies horitzontals semitransparents cada 2 píxels.
  • Vinyeta: cercles concèntrics foscos als marges per “tubar” el monitor.
    Aquests overlays es recalculen quan canvia la mida de la finestra (resizable / fullscreen).

5) Contorns reals amb Shapefile (Natural Earth)

Lectura del Shapefile

S’utilitza PyShp per llegir ne_50m_land.shp. Recorda que Shapefile requereix .shx i .dbf al costat del .shp.


import shapefile

r = shapefile.Reader("ne_50m_land.shp")
for shape in r.shapes():
    pts = shape.points           # llista [(x, y), ...] en WGS84 (lon/lat)
    parts = list(shape.parts) + [len(pts)]  # parts = anells
    # ...

Anells i “forats” (holes)

  • Un polígon pot tenir anells exteriors i interiors.
  • Per norma habitual, els exteriors solen ser horaris (CW) i els forats antihoraris (CCW).
  • El codi calcula l’àrea signada (shoelace) per distingir-los i, per defecte, ignora els forats (es poden activar amb el botó HOLES).

Simplificació (RDP) i dateline

  • Ramer–Douglas–Peucker (RDP): redueix vèrtexs mantenint la forma; paràmetre epsilon ajustable (0.04 per defecte) per balancejar detall i FPS.
  • Dateline (±180°): si un segment “salta” ~>170° de longitud, es segmenta en trams separats per evitar línies que travessen tot el mapa.

Resultat: el dibuix és continu, fiable al meridià ±180° i prou lleuger per temps real.


6) Grid i “blips” de ciutats

  • Meridians/Paral·lels: línies discontínues amb gruix/color diferenciat per les principals (cada 30° i 20°).
  • Etiquetes de graus: sobre i sota per meridians; a esquerra/dreta pels paral·lels.
  • Ciutats: punts amb glow verd i etiqueta estable sense “tremolor”.

7) ISS en temps real

  • Consulta a l’API http://api.open-notify.org/iss-now.json cada 5 segons.
  • Rumb: es calcula l’angle geodèsic aproximat entre l’última i la penúltima posició per orientar el marcador triangular.
  • Traça: llista de punts (lat, lon) limitada (p. ex. 720) i segmentada al dateline per evitar salts.


8) Botons tàctils (128×64) autoajustables

Classe Button

Gestió del rectangle, estat “premut” i dibuix amb borde verd neó i text centrat.

Distribució automàtica i reserva d’espai

La funció layout_buttons(...) calcula:

  • Quants botons caben per fila segons l’amplada.
  • Quantes files calen.
  • Un rectangle de “softbar” al peu.
    El rect del mapa es redueix per no trepitjar la barra de botons.

Esdeveniments tàctils

En SDL2/Pygame 2, molts tocs arriben com a MOUSEBUTTONDOWN. Amb això, els botons són tàctils sense canvis.


9) Controls disponibles

  • Teclat:
    G Grid · C Cities · S Scanlines · O Outline img · L Land outline · I ISS trail · H Holes · R Refresh · P Screenshot · F Fullscreen · ESC/Q Sortir
  • Tàctil: els botons al peu repliquen exactament aquestes accions.

10) Rendiment i consells

  • Si el FPS baixa (sobretot amb shapefile 10m), puja simplify_eps (per ex. 0.06).
  • Si no tens el shapefile a mà, el codi fa fallback a un perfil low‑poly integrat.
  • Les scanlines/vinyeta són overlays; pots desactivar-les per guanyar FPS.

11) Com executar

Assegura’t que el shapefile està al mateix directori o adapta la ruta dins del codi. Per a pantalles tàctils, simplement obre en fullscreen (F) i utilitza la barra de botons.


12) Depuració habitual

  • “No se encontró shapefile: ne_50m_land.shp” → col·loca .shp/.shx/.dbf al directori del script (o ajusta la ruta).
  • Baix rendiment → puja simplify_eps i/o desactiva SCANLINES.
  • No respon el botó → comprova que MOUSEBUTTONDOWN no estigui capturat per cap altre handler i que btn.rect col·lisiona amb el mouse pos.

13) Fragments de codi clau (copiables al post)

a) Carregar i preparar els contorns (PyShp + RDP + dateline)

b) Dibuix de la traça ISS sense salts al ±180°

c) Botons tàctils autoajustables (128×64)


14) Llicència i dades

  • El codi de l’entrada el pots publicar amb la llicència que prefereixis.
  • Les dades Natural Earth són de domini públic (ideal per a projectes oberts).
  • L’API open-notify és oberta; si vols més metadades de l’ISS, es pot integrar TLE i propagadors (SGP4) en futurs posts.

15) Descàrrega i variants

  • Variant sense shapefile (només low‑poly) per a demostracions ràpides.
  • Variant GeoJSON (en lloc de Shapefile) si prefereixes no dependre de PyShp.
  • Afegir rius/llacs o límit polític (altres capes Natural Earth).
  • Mode “Night Terminator” (gradients dia/nit) per donar un toc astronòmic.
# -*- coding: utf-8 -*-
"""
WARGAMES + ISS tracker (Pygame)
- Contornos reales desde shapefile Natural Earth 50m (ne_50m_land.shp)
- Botones táctiles 128x64 auto-ajustables en la parte inferior

Dependencias:
  pip install pygame requests pyshp
Coloca en el mismo directorio:
  ne_50m_land.shp, ne_50m_land.shx, ne_50m_land.dbf (y opcionalmente .prj)
"""

import os
import math
import random
import time
from datetime import datetime

import pygame
import requests

# --- PyShp (shapefile) ---
try:
    import shapefile
except ImportError:
    shapefile = None

# ----------------------- Config -----------------------
WIDTH, HEIGHT = 1280, 720
MAP_MARGIN = 60
BG_COLOR = (5, 8, 6)
NEON = (0, 255, 100)
GRID_COLOR = (0, 180, 90)
TEXT_COLOR = (120, 255, 170)
SCANLINE_ALPHA = 28
VIGNETTE_ALPHA = 80
FPS = 60

# Botones (tamaño fijo, distribución auto)
BTN_W, BTN_H =100, 64
BTN_GAP = 10          # separación entre botones
BTN_PAD = 12          # padding inferior
BTN_RADIUS = 10       # esquinas redondeadas

LAT_MAJOR = list(range(-80, 81, 20))
LAT_MINOR = list(range(-90, 91, 10))
LON_MAJOR = list(range(-180, 181, 30))
LON_MINOR = list(range(-180, 181, 10))

# ------------------------ Ciudades para el estilo WarGames ------------------------
CITIES = [
    ("Washington", 38.90, -77.04), ("New York", 40.71, -74.01),
    ("Los Angeles", 34.05, -118.24), ("London", 51.51, -0.13),
    ("Paris", 48.86, 2.35), ("Moscow", 55.75, 37.62),
    ("Berlin", 52.52, 13.40), ("Tokyo", 35.68, 139.69),
    ("Beijing", 39.90, 116.40), ("Delhi", 28.61, 77.21),
    ("Sydney", -33.87, 151.21), ("Cairo", 30.04, 31.24),
    ("Johannesburg", -26.20, 28.04), ("Rio de Janeiro", -22.90, -43.20),
    ("Buenos Aires", -34.60, -58.38),
]

# ------------------------ Utilidades de proyección y estilo ------------------------
def lonlat_to_xy(lon, lat, rect):
    x = rect.left + (lon + 180.0) / 360.0 * rect.width
    y = rect.top + (90.0 - lat) / 180.0 * rect.height
    return int(x), int(y)

def draw_dashed_line(surface, color, start, end, dash_len=10, gap_len=6, width=1):
    sx, sy = start; ex, ey = end
    dx, dy = ex - sx, ey - sy
    dist = math.hypot(dx, dy)
    if dist == 0: return
    vx, vy = dx / dist, dy / dist
    drawn = 0.0
    while drawn < dist:
        seg = min(dash_len, dist - drawn)
        x1 = int(sx + vx * drawn); y1 = int(sy + vy * drawn)
        x2 = int(sx + vx * (drawn + seg)); y2 = int(sy + vy * (drawn + seg))
        pygame.draw.line(surface, color, (x1, y1), (x2, y2), width)
        drawn += dash_len + gap_len

def draw_glow_point(surface, x, y, base_color=NEON, r=6):
    glow = pygame.Surface((r*6, r*6), pygame.SRCALPHA)
    cx = cy = r*3
    for i, alpha in enumerate([140, 90, 60, 30, 15]):
        pygame.draw.circle(glow, (*base_color, alpha), (cx, cy), r + i*2)
    surface.blit(glow, (x - cx, y - cy))
    pygame.draw.circle(surface, base_color, (x, y), max(1, r//2))

def make_scanlines(w, h, alpha=SCANLINE_ALPHA):
    overlay = pygame.Surface((w, h), pygame.SRCALPHA)
    line_color = (0, 0, 0, alpha)
    for y in range(0, h, 2):
        overlay.fill(line_color, rect=pygame.Rect(0, y, w, 1))
    return overlay

def make_vignette(w, h, alpha=VIGNETTE_ALPHA):
    vignette = pygame.Surface((w, h), pygame.SRCALPHA)
    center = (w/2, h/2)
    max_r = math.hypot(w/2, h/2)
    for i in range(0, int(max_r), 8):
        a = int(alpha * (i / max_r))
        pygame.draw.circle(vignette, (0, 0, 0, a), center, i, width=8)
    return vignette

def text(surface, font, s, pos, color=TEXT_COLOR, shadow=True):
    if shadow:
        sh = font.render(s, True, (20, 40, 30))
        surface.blit(sh, (pos[0]+1, pos[1]+1))
    img = font.render(s, True, color)
    surface.blit(img, pos)

# ------------------------ LOW-POLY integrado (fallback) ------------------------
CONTINENTS = {
    "Africa": [(-5, 35), (0, 36), (15, 33), (25, 33), (29, 31), (31, 22),
               (36, 0), (40, 6), (43, 12), (51, 9), (40, -20), (32, -28),
               (21, -33), (12, -23), (14, -15), (12, -5), (6, 5),
               (-7, 4), (-18, 15), (-15, 30), (-5, 35)],
    "SouthAmerica": [(-62, 10), (-77, 8), (-80, -5), (-78, -15), (-75, -25),
                     (-70, -50), (-62, -55), (-48, -36), (-38, -13), (-35, -6),
                     (-50, 0), (-62, 10)],
    "NorthAmerica": [(-80, 25), (-90, 18), (-117, 32), (-125, 40), (-150, 60),
                     (-140, 70), (-95, 70), (-70, 55), (-66, 45), (-80, 25)],
    "Europe": [(-6, 36), (0, 43), (10, 45), (15, 46), (20, 48),
               (14, 50), (10, 54), (5, 58), (20, 70), (40, 68),
               (30, 60), (20, 52), (36, 45), (29, 41), (12, 40),
               (7, 43), (-6, 36)],
    "Asia": [(41, 40), (50, 42), (60, 45), (70, 50), (100, 70), (140, 70),
             (146, 45), (120, 20), (103, 10), (95, 5), (75, 10),
             (68, 25), (60, 30), (50, 35), (41, 40)],
    "Australia": [(113, -10), (128, -15), (146, -25), (150, -35), (147, -43),
                  (140, -37), (125, -35), (113, -25), (113, -10)],
    "Greenland": [(-45, 60), (-20, 75), (-45, 83), (-60, 75), (-45, 60)],
    "UK": [(-8, 50), (-3, 58), (-1, 57), (-2, 54), (-5, 51), (-8, 50)],
    "Japan": [(141, 45), (141, 41), (139, 36), (135, 33), (130, 32), (140, 39), (141, 45)],
    "Madagascar": [(49, -12), (48, -25), (50, -25), (50, -12), (49, -12)],
    "NewZealand": [(173, -35), (170, -45), (167, -46), (175, -38), (173, -35)],
    "Antarctica": [(-180, -65), (-120, -75), (-60, -80), (0, -78), (60, -75),
                   (120, -70), (180, -65), (-180, -65)]
}

# ------------------------ ISS ------------------------
ISS_ALT_KM = 420
ISS_SPEED_KMH = 27600

def fetch_iss_position(timeout=8):
    url = "http://api.open-notify.org/iss-now.json"
    r = requests.get(url, timeout=timeout)
    data = r.json()
    lat = float(data["iss_position"]["latitude"])
    lon = float(data["iss_position"]["longitude"])
    ts = int(data.get("timestamp", time.time()))
    return lat, lon, ts

def normalize_lon(lon):
    while lon > 180: lon -= 360
    while lon < -180: lon += 360
    return lon

def bearing_deg(lat1, lon1, lat2, lon2):
    φ1, λ1, φ2, λ2 = map(math.radians, [lat1, lon1, lat2, lon2])
    dλ = λ2 - λ1
    y = math.sin(dλ) * math.cos(φ2)
    x = math.cos(φ1) * math.sin(φ2) - math.sin(φ1) * math.cos(φ2) * math.cos(dλ)
    θ = math.degrees(math.atan2(y, x))
    return (θ + 360) % 360

def draw_iss_marker(surface, rect, lat, lon, prev_lat=None, prev_lon=None):
    lon = normalize_lon(lon)
    x, y = lonlat_to_xy(lon, lat, rect)
    draw_glow_point(surface, x, y, NEON, r=7)
    angle = 0.0
    if prev_lat is not None and prev_lon is not None:
        angle = bearing_deg(prev_lat, prev_lon, lat, lon)
    rad = math.radians(90 - angle)
    size = 10
    tip = (x + int(math.cos(rad) * size), y - int(math.sin(rad) * size))
    left = (x + int(math.cos(rad + 2.7) * size*0.7), y - int(math.sin(rad + 2.7) * size*0.7))
    right = (x + int(math.cos(rad - 2.7) * size*0.7), y - int(math.sin(rad - 2.7) * size*0.7))
    pygame.draw.polygon(surface, NEON, [tip, left, right], width=2)
    text(surface, pygame.font.SysFont("Consolas", 16), "ISS", (x + 12, y - 18))

def draw_iss_trail(surface, rect, trail_lonlat):
    if len(trail_lonlat) < 2: return
    segment = []
    last_lon = None
    for (lat, lon) in trail_lonlat:
        lon = normalize_lon(lon)
        if last_lon is not None and abs(lon - last_lon) > 170:
            if len(segment) > 1:
                pts = [lonlat_to_xy(normalize_lon(lo), la, rect) for (la, lo) in segment]
                pygame.draw.lines(surface, NEON, False, pts, 2)
            segment = []
        segment.append((lat, lon))
        last_lon = lon
    if len(segment) > 1:
        pts = [lonlat_to_xy(normalize_lon(lo), la, rect) for (la, lo) in segment]
        pygame.draw.lines(surface, NEON, False, pts, 2)

# ------------------------ RDP + dateline ------------------------
def _perp_dist(p, a, b):
    (px, py), (ax, ay), (bx, by) = p, a, b
    if a == b:
        return math.hypot(px - ax, py - ay)
    t = ((px - ax) * (bx - ax) + (py - ay) * (by - ay)) / ((bx - ax)**2 + (by - ay)**2)
    t = max(0.0, min(1.0, t))
    x = ax + t * (bx - ax); y = ay + t * (by - ay)
    return math.hypot(px - x, py - y)

def simplify_rdp(points, epsilon=0.04):
    if len(points) < 3 or epsilon <= 0:
        return points[:]
    keep = [False] * len(points)
    keep[0] = keep[-1] = True
    stack = [(0, len(points) - 1)]
    while stack:
        start, end = stack.pop()
        max_dist = 0.0; idx = None
        a, b = points[start], points[end]
        for i in range(start + 1, end):
            d = _perp_dist(points[i], a, b)
            if d > max_dist:
                max_dist, idx = d, i
        if max_dist > epsilon and idx is not None:
            keep[idx] = True
            stack.append((start, idx))
            stack.append((idx, end))
    return [pt for i, pt in enumerate(points) if keep[i]]

def segment_for_dateline(ring):
    if not ring: return []
    segments = []
    seg = [(normalize_lon(ring[0][0]), ring[0][1])]
    last_lon = seg[0][0]
    for lon, lat in ring[1:]:
        lon = normalize_lon(lon)
        if abs(lon - last_lon) > 170:
            if len(seg) > 1:
                segments.append(seg)
            seg = []
        seg.append((lon, lat))
        last_lon = lon
    if len(seg) > 1:
        segments.append(seg)
    return segments

def signed_area(ring):
    if len(ring) < 3: return 0.0
    s = 0.0
    for i in range(len(ring)):
        x1, y1 = ring[i]
        x2, y2 = ring[(i+1) % len(ring)]
        s += x1 * y2 - x2 * y1
    return 0.5 * s  # >0 CCW (agujero), <0 CW (exterior)

# ------------------------ Shapefile Natural Earth ------------------------
def load_shp_land_segments(path, simplify_eps=0.04, include_holes=False):
    if shapefile is None:
        print("PyShp no está instalado. Instala con: pip install pyshp")
        return None
    if not os.path.isfile(path):
        print("No se encontró shapefile:", path)
        return None
    try:
        r = shapefile.Reader(path)
    except Exception as e:
        print("Error leyendo shapefile:", e)
        return None

    segments = []
    for shp in r.shapes():
        pts = shp.points
        parts = list(shp.parts) + [len(pts)]
        for i in range(len(parts) - 1):
            start = parts[i]; end = parts[i+1]
            ring = [(float(x), float(y)) for (x, y) in pts[start:end]]
            if ring and ring[0] != ring[-1]:
                ring.append(ring[0])
            area = signed_area(ring)
            is_hole = area > 0  # CCW => agujero
            if not include_holes and is_hole:
                continue
            ring_simpl = simplify_rdp(ring, epsilon=simplify_eps)
            segs = segment_for_dateline(ring_simpl)
            segments.extend(segs)
    return segments

# ------------------------ Grid y ciudades ------------------------
def draw_grid(surface, rect, font):
    for lon in LON_MINOR:
        x1, y1 = lonlat_to_xy(lon, -90, rect)
        x2, y2 = lonlat_to_xy(lon, 90, rect)
        color = GRID_COLOR if lon % 30 != 0 else NEON
        width = 1 if lon % 30 != 0 else 2
        draw_dashed_line(surface, color, (x1, y1), (x2, y2), dash_len=12, gap_len=6, width=width)
    for lat in LAT_MINOR:
        x1, y1 = lonlat_to_xy(-180, lat, rect)
        x2, y2 = lonlat_to_xy(180, lat, rect)
        color = GRID_COLOR if lat % 20 != 0 else NEON
        width = 1 if lat % 20 != 0 else 2
        draw_dashed_line(surface, color, (x1, y1), (x2, y2), dash_len=12, gap_len=6, width=width)
    for lon in LON_MAJOR:
        xt, yt_top = lonlat_to_xy(lon, 90, rect)
        _, yt_bottom = lonlat_to_xy(lon, -90, rect)
        label = f"{lon}°"
        text(surface, font, label, (xt-14, yt_top-24))
        text(surface, font, label, (xt-14, yt_bottom+8))
    for lat in LAT_MAJOR:
        xl, yl = lonlat_to_xy(-180, lat, rect)
        xr, yr = lonlat_to_xy(180, lat, rect)
        hemi = "N" if lat > 0 else ("S" if lat < 0 else "")
        label = f"{abs(lat)}°{hemi}"
        text(surface, font, label, (xl-50, yl-10))
        text(surface, font, label, (xr+12, yr-10))

def draw_cities(surface, rect, font):
    for name, lat, lon in CITIES:
        x, y = lonlat_to_xy(lon, lat, rect)
        draw_glow_point(surface, x, y, NEON, r=6)
        text(surface, font, name.upper(), (x + 10, y - 14))

# ------------------------ Botones táctiles ------------------------
class Button:
    def __init__(self, key, label):
        self.key = key        # identificador lógico
        self.label = label    # texto del botón
        self.rect = pygame.Rect(0, 0, BTN_W, BTN_H)
        self.pressed = False

    def draw(self, surface, font):
        # Fondo boton
        bg = (10, 20, 15) if not self.pressed else (20, 40, 30)
        pygame.draw.rect(surface, bg, self.rect, border_radius=BTN_RADIUS)
        # Borde neón
        pygame.draw.rect(surface, NEON, self.rect, width=2, border_radius=BTN_RADIUS)
        # Etiqueta centrada
        label = font.render(self.label, True, TEXT_COLOR)
        lx = self.rect.centerx - label.get_width()//2
        ly = self.rect.centery - label.get_height()//2
        surface.blit(label, (lx, ly))

def layout_buttons(buttons, screen_w, screen_h):
    """
    Calcula posición de botones en filas al pie de pantalla.
    Devuelve (softbar_rect, rows_count).
    """
    # ¿cuántos caben por fila?
    inner_w = screen_w - MAP_MARGIN*2
    per_row = max(1, (inner_w + BTN_GAP) // (BTN_W + BTN_GAP))
    rows = math.ceil(len(buttons) / per_row)

    # Altura total de la barra
    softbar_h = rows*BTN_H + (rows-1)*BTN_GAP + BTN_PAD
    softbar_rect = pygame.Rect(
        MAP_MARGIN,
        screen_h - softbar_h - BTN_PAD,
        inner_w,
        softbar_h
    )

    # Colocar cada botón
    x0 = softbar_rect.left
    y_start = softbar_rect.bottom - BTN_PAD - rows*BTN_H - (rows-1)*BTN_GAP
    for idx, btn in enumerate(buttons):
        row = idx // per_row
        col = idx % per_row
        # centrar la fila si hay hueco
        used_w = min(per_row, len(buttons) - row*per_row)*(BTN_W + BTN_GAP) - BTN_GAP
        row_x0 = x0 + (inner_w - used_w)//2
        x = row_x0 + col*(BTN_W + BTN_GAP)
        y = y_start + row*(BTN_H + BTN_GAP)
        btn.rect.topleft = (x, y)

    return softbar_rect, rows

# ------------------------ Dibujo de continentes ------------------------
def draw_continents_outline(screen, rect, dash=False, from_segments=None):
    color = NEON; width = 2
    def draw_segment(seg):
        pts = [lonlat_to_xy(lon, lat, rect) for (lon, lat) in seg]
        for i in range(len(pts) - 1):
            if dash:
                draw_dashed_line(screen, color, pts[i], pts[i+1], dash_len=12, gap_len=6, width=width)
            else:
                pygame.draw.line(screen, color, pts[i], pts[i+1], width)
    if from_segments:
        for seg in from_segments:
            draw_segment(seg)
    else:
        for _, poly in CONTINENTS.items():
            pts = [lonlat_to_xy(lon, lat, rect) for (lon, lat) in poly]
            for i in range(len(pts) - 1):
                pygame.draw.line(screen, color, pts[i], pts[i+1], width)
            if len(pts) > 1:
                pygame.draw.line(screen, color, pts[-1], pts[0], width)

# ------------------------ Main ------------------------
def main():
    pygame.init()
    flags = pygame.RESIZABLE
    screen = pygame.display.set_mode((WIDTH, HEIGHT), flags)
    pygame.display.set_caption("WARGAMES - GLOBAL MAP GRID + ISS TRACKER (Touch Buttons)")
    clock = pygame.time.Clock()

    try:
        font = pygame.font.SysFont("Consolas", 16)
        big = pygame.font.SysFont("Consolas", 20)
    except:
        font = pygame.font.SysFont(None, 16)
        big = pygame.font.SysFont(None, 20)

    scanlines = make_scanlines(WIDTH, HEIGHT, SCANLINE_ALPHA)
    vignette = make_vignette(WIDTH, HEIGHT, VIGNETTE_ALPHA)

    outline_img = None
    if os.path.isfile("world_outline.png"):
        try:
            img = pygame.image.load("world_outline.png").convert_alpha()
            outline_img = img
        except Exception as e:
            print("No se pudo cargar world_outline.png:", e)

    # Shapefile Land
    land_shp_path = "ne_50m_land.shp"
    include_holes = False
    simplify_eps = 0.04
    land_segments = load_shp_land_segments(land_shp_path, simplify_eps=simplify_eps, include_holes=include_holes)
    if land_segments:
        print(f"[LAND] cargados {len(land_segments)} segmentos desde {land_shp_path}")
    else:
        print("[LAND] usando perfil low‑poly integrado (no se cargó shapefile).")

    # Estados de visualización
    show_grid = True
    show_cities = True
    show_scanlines = True
    show_outline_img = outline_img is not None
    show_land_outline = True
    show_iss_trail = True
    fullscreen = False

    # ISS
    iss_lat = iss_lon = iss_ts = None
    prev_lat = prev_lon = None
    trail = []
    last_fetch_ms = 0
    FETCH_INTERVAL_MS = 5000

    # --- Botones táctiles ---
    buttons = [
        Button("grid",        "GRID"),
        Button("cities",      "CITIES"),
        Button("scanlines",   "SCANLINES"),
        Button("outline_img", "OUTLINE IMG"),
        Button("land",        "LAND OUTLINE"),
        Button("iss_trail",   "ISS TRAIL"),
        Button("holes",       "HOLES"),
        Button("refresh",     "REFRESH"),
        Button("screenshot",  "SHOT"),
        Button("fullscreen",  "FULLSCREEN"),
        Button("quit",        "QUIT"),
    ]

    running = True
    while running:
        dt = clock.tick(FPS) / 1000.0

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

            elif event.type == pygame.KEYDOWN:
                if event.key in (pygame.K_ESCAPE, pygame.K_q):
                    running = False
                elif event.key == pygame.K_g:
                    show_grid = not show_grid
                elif event.key == pygame.K_c:
                    show_cities = not show_cities
                elif event.key == pygame.K_s:
                    show_scanlines = not show_scanlines
                elif event.key == pygame.K_o:
                    show_outline_img = not show_outline_img
                elif event.key == pygame.K_l:
                    show_land_outline = not show_land_outline
                elif event.key == pygame.K_i:
                    show_iss_trail = not show_iss_trail
                elif event.key == pygame.K_h:
                    include_holes = not include_holes
                    land_segments = load_shp_land_segments(land_shp_path, simplify_eps=simplify_eps, include_holes=include_holes)
                elif event.key == pygame.K_r:
                    last_fetch_ms = 0
                elif event.key == pygame.K_p:
                    pygame.image.save(screen, "wargames_iss.png")
                elif event.key == pygame.K_f:
                    fullscreen = not fullscreen
                    if fullscreen:
                        screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
                    else:
                        screen = pygame.display.set_mode((WIDTH, HEIGHT), flags)

            # Táctil / ratón sobre botones
            elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
                mx, my = event.pos
                for btn in buttons:
                    if btn.rect.collidepoint(mx, my):
                        btn.pressed = True
                        # Ejecutar acción
                        if btn.key == "grid":
                            show_grid = not show_grid
                        elif btn.key == "cities":
                            show_cities = not show_cities
                        elif btn.key == "scanlines":
                            show_scanlines = not show_scanlines
                        elif btn.key == "outline_img":
                            show_outline_img = not show_outline_img
                        elif btn.key == "land":
                            show_land_outline = not show_land_outline
                        elif btn.key == "iss_trail":
                            show_iss_trail = not show_iss_trail
                        elif btn.key == "holes":
                            include_holes = not include_holes
                            land_segments = load_shp_land_segments(land_shp_path, simplify_eps=simplify_eps, include_holes=include_holes)
                        elif btn.key == "refresh":
                            last_fetch_ms = 0
                        elif btn.key == "screenshot":
                            pygame.image.save(screen, "wargames_iss.png")
                        elif btn.key == "fullscreen":
                            fullscreen = not fullscreen
                            if fullscreen:
                                screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
                            else:
                                screen = pygame.display.set_mode((WIDTH, HEIGHT), flags)
                        elif btn.key == "quit":
                            running = False

            elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
                for btn in buttons:
                    btn.pressed = False

        w, h = screen.get_size()
        if scanlines.get_size() != (w, h):
            scanlines = make_scanlines(w, h, SCANLINE_ALPHA)
            vignette = make_vignette(w, h, VIGNETTE_ALPHA)

        # Distribuir botones y reservar altura de barra
        softbar_rect, rows = layout_buttons(buttons, w, h)
        softbar_h = rows*BTN_H + (rows-1)*BTN_GAP + BTN_PAD*2

        # Fondo con leve flicker
        flicker = random.randint(0, 2)
        screen.fill((BG_COLOR[0] + flicker, BG_COLOR[1], BG_COLOR[2]))

        # Rect del mapa, restando barra inferior de botones
        rect = pygame.Rect(MAP_MARGIN, MAP_MARGIN, w - MAP_MARGIN*2, h - MAP_MARGIN*2 - softbar_h)
        pygame.draw.rect(screen, NEON, rect, width=2)

        # Outline por imagen (opcional)
        if show_outline_img and outline_img is not None:
            img = pygame.transform.smoothscale(outline_img, (rect.width, rect.height))
            tint = pygame.Surface(img.get_size(), pygame.SRCALPHA)
            tint.fill((*NEON, 0))
            img.blit(tint, (0, 0), special_flags=pygame.BLEND_ADD)
            screen.blit(img, rect.topleft)

        # Perfil continental
        if show_land_outline:
            draw_continents_outline(screen, rect, dash=False, from_segments=land_segments)

        # Grid / ciudades
        if show_grid:
            draw_grid(screen, rect, font)
        if show_cities:
            draw_cities(screen, rect, font)

        # Actualización ISS cada 5s
        now_ms = pygame.time.get_ticks()
        if now_ms - last_fetch_ms > 5000:
            last_fetch_ms = now_ms
            try:
                lat, lon, ts = fetch_iss_position(timeout=8)
                prev_lat, prev_lon = iss_lat, iss_lon
                iss_lat, iss_lon, iss_ts = lat, lon, ts
                if iss_lat is not None and iss_lon is not None:
                    trail.append((iss_lat, iss_lon))
                    if len(trail) > 720: trail.pop(0)
            except Exception:
                pass

        if show_iss_trail and trail:
            draw_iss_trail(screen, rect, trail)
        if iss_lat is not None and iss_lon is not None:
            draw_iss_marker(screen, rect, iss_lat, iss_lon, prev_lat, prev_lon)

        # Título / HUD superior
        title = "GLOBAL THERMAL MAP INTERFACE // WARGAMES"
        text(screen, big, title, (MAP_MARGIN, 18))

        # Dibujar barra y botones
        # (ligero fondo semitransparente debajo para contraste)
        bar_bg = pygame.Surface((softbar_rect.width, softbar_rect.height), pygame.SRCALPHA)
        bar_bg.fill((0, 0, 0, 80))
        screen.blit(bar_bg, softbar_rect.topleft)
        for btn in buttons:
            btn.draw(screen, font)

        # Efectos CRT
        if show_scanlines:
            screen.blit(scanlines, (0, 0))
            screen.blit(vignette, (0, 0))

        pygame.display.flip()

    pygame.quit()

if __name__ == "__main__":
    main()