#!/usr/bin/env python3 """Carte globale du site (carte_globale.html), affichée en grand sur l'accueil. Fond OpenStreetMap (ou satellite Esri), sans Google. Elle réunit, en calques que l'on peut afficher ou masquer : - le contenu récupéré de cartes Google My Maps (anciennes balades et voyages), avec leurs couleurs d'origine, un calque par calque My Maps ; - les voyages du site (rubrique Voyages, pas les balades du site), chaque trace menant à la page du voyage. Récupération des cartes My Maps : - mymaps.txt à la racine du site : un lien de carte My Maps par ligne (# pour un commentaire), carte partagée « Tous les utilisateurs disposant du lien ». Le KML est téléchargé UNE fois dans _mymaps/.kml ; ensuite la carte ne dépend plus de Google. MYMAPS_MAJ=1 pour le récupérer à nouveau après avoir modifié la carte Google. - ou tout export .kml / .kmz déposé à la main dans _mymaps/. Appelé par genere_index_general.py ; utilisable seul : ./genere_carte_globale.py [racine_du_site] """ import json import math import os import re import shutil import subprocess import sys import urllib.request import zipfile import xml.etree.ElementTree as ET from urllib.parse import parse_qs, urlparse import folium import gpxpy from folium import plugins from jinja2 import Environment, FileSystemLoader from markupsafe import Markup, escape DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' DOSSIER_MYMAPS = "_mymaps" SORTIE = "carte_globale.html" # Une couleur par voyage, du plus récent au plus ancien. COULEURS_VOYAGES = ['#E74C3C', '#2980B9', '#27AE60', '#8E44AD', '#F39C12', '#16A085', '#D35400', '#2C3E50', '#C0392B', '#7F8C8D'] COULEUR_MYMAPS = '#8E44AD' # si le KML ne précise pas de couleur # Écart toléré (m) en simplifiant les traces : à l'échelle de la France, # 30 m sont invisibles et allègent beaucoup la page. TOLERANCE_M = 30 env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True, trim_blocks=True, lstrip_blocks=True) def liens_et_lignes(texte): """Texte brut → HTML sûr : échappé, adresses http(s) cliquables, retours à la ligne.""" h = str(escape(texte)) h = re.sub(r'(https?://[^\s<]+)', r'\1', h) return Markup(h.replace('\n', '
')) env.filters['liens_et_lignes'] = liens_et_lignes F = env.get_template("fragments_globale.html").module # --- Traces des voyages du site --------------------------------------------- def eclaircir(points, ecart_m): """Ne garde que les points distants d'au moins ecart_m du précédent gardé (calcul approché, rapide). Divise d'autant le travail de simplify(), lent en Python sur les 100 000 points d'un grand voyage.""" if len(points) < 3: return points garde = [points[0]] seuil = (ecart_m / 111_320.0) ** 2 # degrés², à peu près for p in points[1:-1]: q = garde[-1] dlat = p.latitude - q.latitude dlon = (p.longitude - q.longitude) * math.cos(math.radians(p.latitude)) if dlat * dlat + dlon * dlon >= seuil: garde.append(p) garde.append(points[-1]) return garde def longueur_km(lignes): total = 0.0 for l in lignes: for (la1, lo1), (la2, lo2) in zip(l, l[1:]): dlat = math.radians(la2 - la1) dlon = math.radians(lo2 - lo1) * math.cos(math.radians((la1 + la2) / 2)) total += 6371.0 * math.hypot(dlat, dlon) return total def trace_voyage(dossier): """Lignes simplifiées [[lat, lon], …] d'un voyage et sa longueur en km. Source : assets/trace.json (journées séparées, déjà simplifiées par genere_carte.py) ; à défaut .kml (une seule ligne : les journées y sont reliées en ligne droite) ou _complet.gpx (lent). """ nom = os.path.basename(dossier) json_trace = os.path.join(dossier, 'assets', 'trace.json') if os.path.exists(json_trace): with open(json_trace, encoding='utf-8') as f: lignes = json.load(f) km = None kml = os.path.join(dossier, f"{nom}.kml") if os.path.exists(kml): with open(kml, encoding='utf-8') as f: m = re.search(r'(\d+) km
', f.read()) km = float(m.group(1)) if m else None return lignes, km if km is not None else longueur_km(lignes) kml = os.path.join(dossier, f"{nom}.kml") gpx_complet = os.path.join(dossier, f"{nom}_complet.gpx") brutes, km = [], None if os.path.exists(kml): with open(kml, encoding='utf-8') as f: texte = f.read() # Distance exacte calculée par genere_carte.py sur la trace complète # (recalculée ici, elle compterait les sauts entre deux journées). m = re.search(r'(\d+) km
', texte) km = float(m.group(1)) if m else None for bloc in re.findall(r'(.*?)', texte, flags=re.S): pts = [] for c in bloc.split(): v = c.split(',') pts.append(gpxpy.gpx.GPXTrackPoint(float(v[1]), float(v[0]))) brutes.append(pts) elif os.path.exists(gpx_complet): with open(gpx_complet, encoding='utf-8') as f: gpx = gpxpy.parse(f) brutes = [seg.points for t in gpx.tracks for seg in t.segments] km = gpx.length_2d() / 1000.0 lignes = [] for pts in brutes: s = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2)) s.simplify(max_distance=TOLERANCE_M) if len(s.points) > 1: lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in s.points]) return lignes, km if km is not None else longueur_km(lignes) # --- Cartes My Maps ---------------------------------------------------------- def mid_de(lien): mid = parse_qs(urlparse(lien).query).get('mid') return mid[0] if mid else None def telecharger_mymaps(racine): """Récupère le KML des cartes listées dans mymaps.txt vers _mymaps/.kml (une fois ; MYMAPS_MAJ=1 pour forcer).""" liste = os.path.join(racine, "mymaps.txt") if not os.path.exists(liste): return dossier = os.path.join(racine, DOSSIER_MYMAPS) os.makedirs(dossier, exist_ok=True) with open(liste, encoding='utf-8') as f: liens = [l.strip() for l in f if l.strip() and not l.strip().startswith('#')] for lien in liens: mid = mid_de(lien) if not mid: print(f" ⚠️ mymaps.txt : pas un lien My Maps (mid=… manquant) : {lien}") continue dest = os.path.join(dossier, f"{mid}.kml") if os.path.exists(dest) and os.environ.get("MYMAPS_MAJ") != "1": continue url = f"https://www.google.com/maps/d/kml?forcekml=1&mid={mid}" contenu, erreur = telecharger(url) if contenu is not None and b"', '', description, flags=re.I | re.S) t = re.sub(r'', '\n', t, flags=re.I) t = re.sub(r'<[^>]+>', '', t) return t.strip() def nettoyer_nom(nom): """« Track-Pyrenees 2014 _ aller.kmz » → « Pyrenees 2014 – aller ».""" n = re.sub(r'\.km[lz]$', '', nom.strip(), flags=re.I) n = re.sub(r'^(Track|T)\s*-\s*', '', n, flags=re.I) n = re.sub(r'\s+_\s+|\s*:\s+', ' – ', n) return re.sub(r'\s{2,}', ' ', n).strip() def resumer_description(texte): """Les statistiques automatiques de My Maps (« Statistiques calculées… », altitudes à 0) sont réduites à la distance ; une vraie description est gardée.""" if texte.startswith("Statistiques calculées"): m = re.search(r'Distance\s*:\s*([\d\s,.]+)\s*km', texte.replace('\xa0', ' ')) return f"{m.group(1).strip()} km" if m else "" return texte def elements_mymaps(contenu): """KML → (titre du document, {nom de calque: [éléments]}). Élément : {'type': 'ligne'|'point', 'nom', 'description', 'couleur', 'coords'}. """ ns = {'k': 'http://www.opengis.net/kml/2.2'} racine = ET.fromstring(contenu) doc = racine.find('k:Document', ns) if doc is None: return None, {} # Styles : id → couleur ; les StyleMap renvoient vers le style « normal ». styles = {} for st in doc.iter('{http://www.opengis.net/kml/2.2}Style'): # (pas de « or » : un élément XML sans enfant est évalué comme faux) c = st.find('k:LineStyle/k:color', ns) if c is None: c = st.find('k:IconStyle/k:color', ns) if st.get('id') and c is not None and c.text: styles['#' + st.get('id')] = couleur_kml(c.text) for sm in doc.iter('{http://www.opengis.net/kml/2.2}StyleMap'): for paire in sm.findall('k:Pair', ns): if paire.findtext('k:key', '', ns) == 'normal': styles['#' + (sm.get('id') or '')] = styles.get(paire.findtext('k:styleUrl', '', ns)) def coords(el): pts = [] for c in (el.findtext('k:coordinates', '', ns) or '').split(): v = c.split(',') if len(v) >= 2: pts.append([round(float(v[1]), 5), round(float(v[0]), 5)]) return pts calques = {} def visiter(noeud, calque): for enfant in noeud: tag = enfant.tag.split('}')[-1] if tag == 'Folder': visiter(enfant, enfant.findtext('k:name', calque, ns)) elif tag == 'Placemark': base = {'nom': nettoyer_nom(enfant.findtext('k:name', '', ns)), 'description': resumer_description(texte_simple(enfant.findtext('k:description', '', ns))), 'couleur': styles.get(enfant.findtext('k:styleUrl', '', ns)) or COULEUR_MYMAPS} for ls in enfant.iter('{http://www.opengis.net/kml/2.2}LineString'): pts = coords(ls) if len(pts) > 1: calques.setdefault(calque, []).append(dict(base, type='ligne', coords=pts)) for pt in enfant.iter('{http://www.opengis.net/kml/2.2}Point'): pts = coords(pt) if pts: calques.setdefault(calque, []).append(dict(base, type='point', coords=pts[0])) titre = doc.findtext('k:name', 'My Maps', ns) visiter(doc, titre) return titre, calques def simplifier(coords): pts = [gpxpy.gpx.GPXTrackPoint(la, lo) for la, lo in coords] seg = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2)) seg.simplify(max_distance=TOLERANCE_M) return [[round(p.latitude, 5), round(p.longitude, 5)] for p in seg.points] # --- Carte ------------------------------------------------------------------- def generer_carte_globale(racine, rubriques): """rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier'}]}.""" racine = os.path.abspath(racine) telecharger_mymaps(racine) m = folium.Map(tiles=None, scrollWheelZoom=False) folium.TileLayer('OpenStreetMap', name='Plan').add_to(m) folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite', show=False).add_to(m) tous = [] resume = [] # Voyages du site : un calque par voyage, coloré, que la légende permet # d'afficher ou de masquer ; le plus récent en tête de légende. voyages = [v for r in rubriques for v in r['voyages']] calques = [] for i, v in enumerate(voyages): lignes, km = trace_voyage(v['dossier']) if not lignes: continue couleur = COULEURS_VOYAGES[i % len(COULEURS_VOYAGES)] libelle = f"{v['titre']} ({v['moto']})" if v.get('moto') else v['titre'] groupe = folium.FeatureGroup(name=str(F.legende(libelle, couleur))) popup = str(F.popup_voyage(libelle, v['periode'], km, f"{v['chemin']}/index.html")) for l in lignes: folium.PolyLine(l, color=couleur, weight=4, opacity=0.85, tooltip=libelle, popup=folium.Popup(popup, max_width=260)).add_to(groupe) tous.extend(l) calques.append(groupe) for groupe in calques: groupe.add_to(m) resume.append(f"{len(calques)} voyage(s)") # Cartes My Maps dossier = os.path.join(racine, DOSSIER_MYMAPS) fichiers = sorted(f for f in os.listdir(dossier) if f.lower().endswith(('.kml', '.kmz'))) \ if os.path.isdir(dossier) else [] for f in fichiers: try: titre, calques = elements_mymaps(lire_kml(os.path.join(dossier, f))) except Exception as e: print(f" ⚠️ {DOSSIER_MYMAPS}/{f} illisible : {e}") continue # Calques du plus récent au plus ancien (leurs noms commencent par l'année). for nom_calque, elements in sorted(calques.items(), key=lambda kv: kv[0], reverse=True): couleur_calque = elements[0]['couleur'] if elements else COULEUR_MYMAPS groupe = folium.FeatureGroup(name=str(F.legende(nom_calque, couleur_calque))) for e in elements: popup = folium.Popup(str(F.popup_mymaps(e['nom'], e['description'])), max_width=300) if e['type'] == 'ligne': pts = simplifier(e['coords']) folium.PolyLine(pts, color=e['couleur'], weight=4, opacity=0.85, tooltip=e['nom'] or None, popup=popup).add_to(groupe) tous.extend(pts) else: folium.CircleMarker(e['coords'], radius=6, color='#fff', weight=2, fill=True, fill_color=e['couleur'], fill_opacity=1, tooltip=e['nom'] or None, popup=popup).add_to(groupe) tous.append(e['coords']) groupe.add_to(m) resume.append(f"{nom_calque} : {len(elements)}") if tous: lats = [p[0] for p in tous] lons = [p[1] for p in tous] m.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]], padding=(20, 20)) folium.LayerControl(collapsed=False).add_to(m) plugins.Fullscreen(position='topleft').add_to(m) m.get_root().html.add_child(folium.Element(str(F.bouton_accueil()))) # Molette active seulement quand la carte est ouverte seule : intégrée dans # l'accueil, elle capterait le défilement de la page. m.get_root().script.add_child(folium.Element(str(F.script_page(m.get_name())))) m.save(os.path.join(racine, SORTIE)) print(f"🗺️ Carte globale : {SORTIE} ({', '.join(resume)})") if __name__ == "__main__": sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from genere_index_general import TITRE_SECTION_RACINE, collecter_rubriques racine = sys.argv[1] if len(sys.argv) > 1 else "." generer_carte_globale(racine, [r for r in collecter_rubriques(os.path.abspath(racine)) if r['titre'] == TITRE_SECTION_RACINE])