"""Génère les pages d'un voyage. index.html carte générale : traces du voyage, liens par jour mini.html miniature affichée sur la page d'accueil du site html/carte_AAAA-MM-JJ.html une carte par jour, avec photos et vidéos _complet.gpx toutes les traces du voyage réunies .kml le voyage en une seule trace simplifiée (< 5 Mo), à importer dans Google My Maps assets/ feuille de style et script communs Appelé par `rtg generer` (rtg.site). Entrées : export_photos.csv et export_videos.csv (extraction_infos.sh), routes/*.gpx (un fichier par jour, date AAAA-MM-JJ dans le nom), voyage.md, et si présents photos_web/ et apercus/ (prepare_web.py). La mise en page est dans Scripts/templates/. Réglages (site.toml) : adresse_publique, pour les liens du KML. """ import copy import hashlib import html import json import os import re import shutil from urllib.parse import quote import folium import gpxpy import markdown2 import numpy as np import pandas as pd from folium import plugins from folium.plugins import MarkerCluster from jinja2 import Environment, FileSystemLoader from rtg import corrections DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") # Fixés par generer() : racine du site et adresse publique. CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"} ASSETS = ("carte.css", "carte.js") COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E'] COLONNES = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse'] TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' KML_MAX_OCTETS = 4_500_000 # My Maps refuse les fichiers de plus de 5 Mo KML_TOLERANCE_M = 10 # simplification de départ (écart toléré, en mètres) KML_COULEUR = "ff3357ff" # #FF5733 au format KML (aabbggrr) # Photos prises à moins de ~20 m l'une de l'autre : un seul marqueur, en diaporama. TOLERANCE_GROUPE_PHOTOS = 0.0002 env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True, trim_blocks=True, lstrip_blocks=True) F = env.get_template("fragments.html").module def url(chemin): """Chemin encodé pour un src/href (espaces, &, #…).""" return quote(str(chemin)) def est_sidecar(nom): """Fichiers cachés et « ._nom » AppleDouble que macOS dépose sur les disques non-HFS : ce ne sont pas des médias.""" return nom.startswith('.') def bornes(points, marge=0.02, robuste=True): """Rectangle de cadrage [[sud, ouest], [nord, est]] et nombre de points écartés. Avec robuste=True, on écarte les points hors de [Q1 - 3·IQR, Q3 + 3·IQR] : le premier échantillon GPS d'une GoPro est parfois la position de la session précédente, et un seul point aberrant suffit à dézoomer sur toute l'Europe. À désactiver pour les traces GPX, continues par construction : le filtre y rognerait de vrais morceaux d'itinéraire. """ if not points: return None, 0 def quartiles(v): v = sorted(v) n = len(v) return (v[0], v[-1]) if n < 4 else (v[n // 4], v[(3 * n) // 4]) gardes = points if robuste: la1, la3 = quartiles([p[0] for p in points]) lo1, lo3 = quartiles([p[1] for p in points]) # Plancher de tolérance : sur une balade très locale l'IQR est minuscule. ela, elo = max((la3 - la1) * 3, 0.05), max((lo3 - lo1) * 3, 0.05) gardes = [p for p in points if la1 - ela <= p[0] <= la3 + ela and lo1 - elo <= p[1] <= lo3 + elo] or points lats = [p[0] for p in gardes] lons = [p[1] for p in gardes] dla = max((max(lats) - min(lats)) * marge, 0.005) dlo = max((max(lons) - min(lons)) * marge, 0.005) return ([[min(lats) - dla, min(lons) - dlo], [max(lats) + dla, max(lons) + dlo]], len(points) - len(gardes)) def lire_csv(chemin, positions=None): """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour. `positions` : {fichier: (lat, lon)} placés à la main, qui l'emportent.""" vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour']) # Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire. if not os.path.exists(chemin) or os.path.getsize(chemin) == 0: return vide df = pd.read_csv(chemin, names=COLONNES, header=None) for nom, (lat, lon) in (positions or {}).items(): lignes = df['Fichier'].astype(str).str.strip() == nom df.loc[lignes, 'Latitude'] = lat df.loc[lignes, 'Longitude'] = lon df = df[df['Latitude'].notnull() & df['Longitude'].notnull()] df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy() if df.empty: return vide df['DT'] = pd.to_datetime(df['Date_Heure'].astype(str).str.split('.').str[0], format='%Y:%m:%d %H:%M:%S', errors='coerce') df = df.dropna(subset=['DT']) df['Jour'] = df['DT'].dt.date return df.sort_values(by='DT') def racine_site(base_dir): """Dossier racine du site : RT_RACINE, sinon le premier parent qui contient un index.html. Un voyage peut être rangé un niveau plus bas (Balades/Aisne-06-2026) : on ne peut pas supposer le parent direct.""" racine = CONTEXTE["racine"] or os.environ.get('RT_RACINE') if racine: return os.path.abspath(racine) parent = os.path.dirname(base_dir) for _ in range(3): if os.path.exists(os.path.join(parent, 'index.html')): return parent parent = os.path.dirname(parent) return os.path.dirname(base_dir) def lien_racine_site(base_dir): """Chemin relatif vers l'index.html de la racine du site.""" return os.path.relpath(os.path.join(racine_site(base_dir), 'index.html'), base_dir).replace(os.sep, '/') def exporter_kml(base_dir, nom_voyage, pistes, km): """.kml pour Google My Maps : UNE ligne pour tout le voyage (les journées mises bout à bout), avec un lien vers la page du voyage. On ne simplifie davantage que si le fichier dépasse la taille acceptée par My Maps (5 Mo ; pas de limite de points par ligne).""" site = CONTEXTE["site_url"] + quote(os.path.relpath(base_dir, racine_site(base_dir)).replace(os.sep, '/')) + '/' titre = nom_voyage.replace('-', ' ').replace('_', ' ') # Simplification journée par journée (rapide sur des lignes courtes), puis # sur la ligne entière, de plus en plus fort, seulement si elle est trop lourde. seg = gpxpy.gpx.GPXTrackSegment() for piste in pistes: for segment in piste.segments: morceau = copy.deepcopy(segment) morceau.simplify(max_distance=KML_TOLERANCE_M) seg.points.extend(morceau.points) if not seg.points: return tolerance = KML_TOLERANCE_M while True: coords = " ".join(f"{p.longitude:.6f},{p.latitude:.6f}" for p in seg.points) desc = (f"{km:.0f} km
" if km else "") + f'Voir le voyage sur le site' kml = ('\n' f"{html.escape(titre)}" f"{html.escape(titre)}" f"" f"" f"1{coords}" "\n").encode('utf-8') if len(kml) <= KML_MAX_OCTETS or tolerance > 5000: break tolerance *= 1.5 seg.simplify(max_distance=tolerance) nom = f"{nom_voyage}.kml" with open(os.path.join(base_dir, nom), 'wb') as f: f.write(kml) print(f"🌐 KML My Maps : {nom} (1 trace, {len(seg.points)} points, {len(kml) // 1024} Ko, " f"simplifiée à {tolerance:.0f} m)") def installer_assets(base_dir): """Copie carte.css / carte.js dans /assets/ et renvoie une version courte (empreinte du contenu) pour forcer le rechargement après mise à jour.""" dest = os.path.join(base_dir, 'assets') os.makedirs(dest, exist_ok=True) empreinte = hashlib.sha1() for nom in ASSETS: src = os.path.join(DOSSIER_TEMPLATES, nom) shutil.copyfile(src, os.path.join(dest, nom)) with open(src, 'rb') as f: empreinte.update(f.read()) return empreinte.hexdigest()[:8] class Journal: """Récit voyage.md converti en HTML, avec deux raccourcis résolus ici : [Carte du jour](jour:2026-08-01) → lien vers la carte de ce jour [Le col](media:photos/IMG_4831.jpeg) → lien vers un média du voyage Le panneau est injecté à deux profondeurs (index.html à la racine du voyage, cartes du jour dans html/) : un lien écrit en dur serait faux sur l'une des deux, d'où ces marqueurs. Un jour sans carte devient du texte simple. """ def __init__(self, base_dir, jours_valides): self.base_dir = base_dir self.valides = {j.strftime('%Y-%m-%d') for j in jours_valides} self.html = "" chemin = os.path.join(base_dir, 'voyage.md') if os.path.exists(chemin): with open(chemin, 'r', encoding='utf-8') as f: self.html = markdown2.markdown(f.read()) self._signale = set() # chaque problème n'est signalé qu'une fois def _signaler(self, message): if message not in self._signale: self._signale.add(message) print(f" ⚠️ Journal : {message}") def rendu(self, prefixe_cartes, prefixe_media): def jour(m): date, texte = m.group(1), m.group(2) if date not in self.valides: self._signaler(f"lien vers {date}, mais aucune carte pour ce jour") return texte # Le fragment #jour-… ne correspond à aucun id : c'est carte.js qui # le lit pour faire défiler le panneau jusqu'à l'ancre. return (f'{texte}') def media(m): chemin, texte = m.group(1).lstrip('/'), m.group(2) if not os.path.exists(os.path.join(self.base_dir, chemin)): self._signaler(f"média introuvable -> {chemin}") return f'{texte}' h = re.sub(r']*>(.*?)', jour, self.html, flags=re.S) h = re.sub(r']*>(.*?)', media, h, flags=re.S) return F.journal(h) def nouvelle_carte(entete): m = folium.Map(tiles=None) folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite').add_to(m) folium.TileLayer('OpenStreetMap', name='Plan').add_to(m) m.get_root().header.add_child(folium.Element(str(entete))) return m def ajouter(carte, fragment): carte.get_root().html.add_child(folium.Element(str(fragment))) def epingle(couleur, icone): return folium.DivIcon(html=str(F.epingle(couleur, icone)), icon_anchor=(12, 40)) def fichiers_gpx(routes_dir): if not os.path.isdir(routes_dir): return [] return sorted(f for f in os.listdir(routes_dir) if not est_sidecar(f) and f.lower().endswith('.gpx')) def groupes_photos(day_p, base_dir, pre): """Regroupe les photos proches ; renvoie [(position, html, nb_photos)].""" groupes = [] while not day_p.empty: ref = day_p.iloc[0] masque = ((np.abs(day_p['Latitude'] - ref['Latitude']) < TOLERANCE_GROUPE_PHOTOS) & (np.abs(day_p['Longitude'] - ref['Longitude']) < TOLERANCE_GROUPE_PHOTOS)) photos = [] for nom in day_p[masque]['Fichier']: nom = str(nom).strip() # Version allégée si prepare_web.py l'a produite ; le clic ouvre l'original. web = os.path.exists(os.path.join(base_dir, 'photos_web', nom)) photos.append({'nom': nom, 'src': f"{pre}{'photos_web' if web else 'photos'}/{url(nom)}", 'href': f"{pre}photos/{url(nom)}"}) groupes.append(([ref['Latitude'], ref['Longitude']], F.popup_photos(photos), len(photos))) day_p = day_p[~masque] return groupes def popup_video(v_name, base_dir, pre): nom = str(v_name).strip() base = os.path.splitext(nom)[0] apercu = None if os.path.exists(os.path.join(base_dir, 'apercus', base + '.jpg')): apercu = f"{pre}apercus/{url(base)}.jpg" return F.popup_video(nom, f"{pre}videos/{url(base)}_mobile.mp4", f"{pre}videos/{url(base)}.mp4", apercu) def exporter_trace_globale(base_dir, pistes): """assets/trace.json : les journées du voyage, séparées et simplifiées à 30 m, pour la carte globale de l'accueil (genere_carte_globale.py).""" lignes = [] for piste in pistes: for segment in piste.segments: morceau = copy.deepcopy(segment) morceau.simplify(max_distance=30) if len(morceau.points) > 1: lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in morceau.points]) with open(os.path.join(base_dir, 'assets', 'trace.json'), 'w', encoding='utf-8') as f: json.dump(lignes, f, separators=(',', ':')) class Voyage: """Données communes à toutes les pages d'un voyage.""" def __init__(self, base_dir, cfg=None): self.base_dir = base_dir self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage" self.routes_dir = os.path.join(base_dir, "routes") cfg = cfg or {} self.zones = corrections.masques(cfg) pos = corrections.positions(cfg) self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"), pos) self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"), pos) self.points_masques = 0 jours_gpx = set() for f in fichiers_gpx(self.routes_dir): m = re.search(r'(\d{4}-\d{2}-\d{2})', f) if m: jours_gpx.add(pd.to_datetime(m.group(1)).date()) self.jours = sorted(set(self.df_v['Jour']) | set(self.df_p['Jour']) | jours_gpx) self.traces = {} # jour → [(piste, points)] self.gpx_du_jour = {} # jour → nom du fichier GPX def lire_traces(self): for jour in self.jours: day_str = jour.strftime('%Y-%m-%d') self.traces[jour] = [] # Date n'importe où dans le nom du fichier. for gpx_file in fichiers_gpx(self.routes_dir): if day_str not in gpx_file: continue try: with open(os.path.join(self.routes_dir, gpx_file), 'r', encoding='utf-8') as f: gpx = gpxpy.parse(f) except Exception as e: print(f" ⚠️ GPX illisible ({gpx_file}) : {e}") continue self.gpx_du_jour[jour] = gpx_file for track in gpx.tracks: self.points_masques += corrections.filtrer_piste(track, self.zones) pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points] if pts: if not track.name: track.name = day_str self.traces[jour].append((track, pts)) def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telechargement): """Carte d'une journée avec photos et vidéos. pre : chemin vers la racine du voyage depuis la page (« ../ » pour html/carte_JJ.html, « » quand la carte sert de page du voyage). """ day_str = jour.strftime('%Y-%m-%d') m_day = nouvelle_carte(str(F.entete(pre, version)) + f'') cluster = MarkerCluster(name="Médias").add_to(m_day) day_coords, media_coords = [], [] for _, pts in v.traces.get(jour, []): day_coords.extend(pts) folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day) ajouter(m_day, F.bouton_accueil(lien_accueil)) ajouter(m_day, journal.rendu("" if pre else "html/", pre)) if bouton_telechargement: ajouter(m_day, bouton_telechargement) for pos, popup, nb in groupes_photos(v.df_p[v.df_p['Jour'] == jour].copy(), v.base_dir, pre): media_coords.append(pos) folium.Marker(location=pos, popup=folium.Popup(str(popup), max_width="100%"), icon=epingle("#FF3B30", "camera" if nb == 1 else "images")).add_to(cluster) # Vidéos : marqueur au premier point GPS du rush. for v_name, group in v.df_v[v.df_v['Jour'] == jour].groupby('Fichier'): pos = group[['Latitude', 'Longitude']].values.tolist()[0] media_coords.append(pos) folium.Marker(location=pos, popup=folium.Popup(str(popup_video(v_name, v.base_dir, pre)), max_width="100%"), icon=epingle("#007AFF", "play")).add_to(cluster) # Cadrage : la trace GPX en priorité, sinon la position des médias # (sans quoi un jour sans GPX s'ouvre sur une mappemonde). b, rejetes = bornes(day_coords or media_coords, robuste=not day_coords) if b: m_day.fit_bounds(b, padding=(50, 50)) if rejetes and pre: print(f" ℹ️ {day_str} : {rejetes} point(s) GPS aberrant(s) exclu(s) du cadrage") folium.LayerControl(position='topright').add_to(m_day) plugins.Fullscreen(position='topright').add_to(m_day) return m_day, media_coords def generer(base_dir, racine=None, site_url=None, cfg=None): """Pages d'un voyage. `racine` : racine du site (bouton « Retour ») ; `cfg` : réglages du voyage (corrections faites dans l'interface).""" CONTEXTE["racine"] = racine if site_url: CONTEXTE["site_url"] = site_url.rstrip('/') + '/' base_dir = os.path.abspath(base_dir) html_dir = os.path.join(base_dir, "html") os.makedirs(html_dir, exist_ok=True) v = Voyage(base_dir, cfg) if not v.jours: print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).") return False v.lire_traces() if v.points_masques: print(f" 🙈 {v.points_masques} point(s) de trace retiré(s) par les zones masquées") version = installer_assets(base_dir) journal = Journal(base_dir, v.jours) racine = lien_racine_site(base_dir) un_seul_jour = len(v.jours) == 1 # Trace complète du voyage. Écrite à la racine du voyage et non dans # routes/, qui est relu à chaque génération. gpx_complet = gpxpy.gpx.GPX() gpx_complet.name = f"{v.nom} - trace complete" gpx_complet.creator = "genere_carte.py" for jour in v.jours: gpx_complet.tracks.extend(t for t, _ in v.traces[jour]) bouton_complet, km = None, 0 if gpx_complet.tracks: nom_fichier = f"{v.nom}_complet.gpx" with open(os.path.join(base_dir, nom_fichier), 'w', encoding='utf-8') as f: f.write(gpx_complet.to_xml()) nb_pts = sum(len(sg.points) for t in gpx_complet.tracks for sg in t.segments) try: km = gpx_complet.length_2d() / 1000.0 except Exception: km = 0 print(f"🧭 Trace complète : {nom_fichier} ({len(gpx_complet.tracks)} étape(s), {nb_pts} points" + (f", {km:.0f} km" if km else "") + ")") bouton_complet = lambda pre: F.bouton_gpx( # noqa: E731 f"{pre}{url(nom_fichier)}", "📥 Trace complète" + (f" ({km:.0f} km)" if km else "")) exporter_kml(base_dir, v.nom, gpx_complet.tracks, km) exporter_trace_globale(base_dir, gpx_complet.tracks) # --- Cartes du jour (html/carte_JJ.html) liens_jours, coords_medias = [], [] for i, jour in enumerate(v.jours): day_str = jour.strftime('%Y-%m-%d') file_name = f'carte_{day_str}.html' liens_jours.append({'libelle': day_str, 'lien': f'html/{file_name}', 'couleur': COULEURS_JOURS[i % len(COULEURS_JOURS)]}) if un_seul_jour: # La carte du jour est aussi la page du voyage : son bouton mène à # l'accueil du site, et on y télécharge la trace complète. accueil, bouton = "../" + racine, bouton_complet("../") if bouton_complet else None else: gpx = v.gpx_du_jour.get(jour) accueil = "../index.html" bouton = F.bouton_gpx(f"../routes/{url(gpx)}", "📥 GPX") if gpx else None m_day, medias = carte_du_jour(v, jour, version, journal, "../", accueil, bouton) coords_medias.extend(medias) m_day.save(os.path.join(html_dir, file_name)) # --- Miniature pour l'accueil du site coords_traces = [p for j in v.jours for _, pts in v.traces[j] for p in pts] cadrage = coords_traces or coords_medias b, rejetes = bornes(cadrage, robuste=not coords_traces) m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, control_scale=False, attribution_control=False) for i, jour in enumerate(v.jours): for _, pts in v.traces[jour]: folium.PolyLine(pts, color=COULEURS_JOURS[i % len(COULEURS_JOURS)], weight=3, opacity=0.8).add_to(m_mini) if b: m_mini.fit_bounds(b, padding=(20, 20)) m_mini.save(os.path.join(base_dir, "mini.html")) # --- Page du voyage (index.html) if un_seul_jour: # Une seule journée : la page du voyage est directement la carte du # jour, avec photos et vidéos (pas d'étape intermédiaire inutile). m_index, _ = carte_du_jour(v, v.jours[0], version, journal, "", racine, bouton_complet("") if bouton_complet else None) else: m_index = nouvelle_carte(F.entete("", version)) for i, jour in enumerate(v.jours): day_str = jour.strftime('%Y-%m-%d') couleur = COULEURS_JOURS[i % len(COULEURS_JOURS)] popup = F.popup_trace(day_str, couleur, f'html/carte_{day_str}.html') for _, pts in v.traces[jour]: folium.PolyLine(pts, color=couleur, weight=7, opacity=0.8, popup=folium.Popup(str(popup), max_width=200)).add_to(m_index) if b: source = "traces GPX" if coords_traces else "positions des médias" print(f"🗺️ Cadrage général d'après {len(cadrage)} point(s) ({source})" + (f", {rejetes} aberrant(s) exclu(s)" if rejetes else "")) m_index.fit_bounds(b, padding=(50, 50)) if bouton_complet: ajouter(m_index, bouton_complet("")) ajouter(m_index, F.barre_jours(racine, liens_jours)) ajouter(m_index, journal.rendu("html/", "")) folium.LayerControl().add_to(m_index) m_index.save(os.path.join(base_dir, "index.html")) print(f"✅ {v.nom} : {len(v.jours)} jour(s) → index.html" + (" (carte du jour directement)" if un_seul_jour else "") + ", mini.html, html/") return True