#!/usr/bin/env python3 import pandas as pd import folium from folium import plugins from folium.plugins import MarkerCluster import os import gpxpy import re import sys import markdown2 import numpy as np import html from urllib.parse import quote # --- 1. CONFIGURATION --- base_dir = sys.argv[1] if len(sys.argv) > 1 else "./" base_dir = os.path.abspath(base_dir) routes_dir = os.path.join(base_dir, "routes") html_output_dir = os.path.join(base_dir, "html") csv_videos = os.path.join(base_dir, "export_videos.csv") csv_photos = os.path.join(base_dir, "export_photos.csv") file_notes = os.path.join(base_dir, "voyage.md") if not os.path.exists(html_output_dir): os.makedirs(html_output_dir) COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E'] noms_colonnes = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse'] # --- ÉCHAPPEMENT --- # Sans ça, un fichier nommé « col & vue.jpg » produit un lien cassé et du HTML # invalide. quote() encode l'espace et l'esperluette pour les URL, escape() # protège le texte affiché. def est_sidecar(nom): """Fichiers parasites macOS. Sur un disque non-HFS (exFAT d'un disque externe, cle USB, partage reseau), macOS depose un fichier AppleDouble « ._nom » a cote de chaque fichier pour y stocker les metadonnees. « ._Ete_2026-08-01.gpx » n'est donc pas un GPX mais un blob binaire, et gpxpy echoue dessus avec une erreur de decodage. On ecarte aussi .DS_Store et les fichiers caches. """ return nom.startswith('._') or nom.startswith('.') def url(chemin): """Chemin sûr pour un attribut src/href.""" return html.escape(quote(chemin)) def txt(valeur): """Texte sûr pour l'affichage.""" return html.escape(str(valeur)) # --- FONCTIONS UTILES --- def nettoyer_et_trier(df): if df is None or df.empty: return pd.DataFrame(columns=noms_colonnes + ['DT', 'Jour']) df = df[(df['Latitude'].notnull()) & (df['Longitude'].notnull())] df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)] df['DT'] = pd.to_datetime(df['Date_Heure'].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 create_pin(color, icon_name): html_code = f"""
""" return folium.DivIcon(html=html_code, icon_anchor=(12, 40)) def inject_common_assets(m): custom_js = """ """ custom_css = """ """ m.get_root().header.add_child(folium.Element(custom_css + custom_js)) def get_journal_ui(): journal_html = "" if os.path.exists(file_notes): with open(file_notes, 'r', encoding='utf-8') as f: journal_html = markdown2.markdown(f.read()) return f"""
{journal_html}
""" # --- 2. CHARGEMENT --- try: df_v = pd.read_csv(csv_videos, names=noms_colonnes, header=None) if os.path.exists(csv_videos) else pd.DataFrame() df_p = pd.read_csv(csv_photos, names=noms_colonnes, header=None) if os.path.exists(csv_photos) else pd.DataFrame() df_v, df_p = nettoyer_et_trier(df_v), nettoyer_et_trier(df_p) jours_gpx = set() if os.path.exists(routes_dir): for f in os.listdir(routes_dir): if est_sidecar(f): continue match = re.search(r'(\d{4}-\d{2}-\d{2})', f) if match: jours_gpx.add(pd.to_datetime(match.group(1)).date()) tous_les_jours = sorted(list(set(df_v['Jour'].unique()) | set(df_p['Jour'].unique()) | jours_gpx)) except Exception as e: print(f"❌ Erreur : {e}"); sys.exit() # --- 3. GÉNÉRATION --- m_global = folium.Map(tiles=None) folium.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri', name='Satellite').add_to(m_global) folium.TileLayer('OpenStreetMap', name='Plan').add_to(m_global) inject_common_assets(m_global) m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, control_scale=False, attribution_control=False) all_global_coords, sidebar_links_html = [], "" for i, jour in enumerate(tous_les_jours): day_str = jour.strftime('%Y-%m-%d') file_name = f'carte_{day_str}.html' color_day = COULEURS_JOURS[i % len(COULEURS_JOURS)] sidebar_links_html += f'{day_str}' m_day = folium.Map(tiles=None) folium.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri', name='Satellite').add_to(m_day) folium.TileLayer('OpenStreetMap', name='Plan').add_to(m_day) inject_common_assets(m_day) marker_cluster = MarkerCluster(name="Médias").add_to(m_day) day_coords = [] media_coords = [] # position des photos/vidéos, filet de cadrage current_gpx_filename = None if os.path.exists(routes_dir): for gpx_file in os.listdir(routes_dir): # `day_str in gpx_file` et non `.startswith(day_str)` : la liste des # jours est bâtie avec re.search (date N'IMPORTE OÙ dans le nom), # donc une trace « trace_2026-07-15_col.gpx » créait bien un jour # mais n'était jamais retrouvée ici — jour vide, carte non cadrée. if est_sidecar(gpx_file): continue if day_str in gpx_file and gpx_file.lower().endswith('.gpx'): try: with open(os.path.join(routes_dir, gpx_file), 'r') as f: gpx = gpxpy.parse(f) for track in gpx.tracks: pts = [] for segment in track.segments: pts.extend([[p.latitude, p.longitude] for p in segment.points]) if pts: day_coords.extend(pts); all_global_coords.extend(pts) popup_html = f"
📅 {day_str}

Détails
" folium.PolyLine(pts, color=color_day, weight=7, opacity=0.8, popup=folium.Popup(popup_html, max_width=200)).add_to(m_global) folium.PolyLine(pts, color=color_day, weight=3, opacity=0.8).add_to(m_mini) folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day) # Le nom n'est retenu qu'APRES analyse reussie : sinon le # bouton de telechargement pouvait pointer sur un fichier # illisible traite apres la vraie trace. current_gpx_filename = gpx_file except Exception as e: # `except: pass` avalait TOUT, y compris un Ctrl-C, et # masquait les GPX corrompus : la trace disparaissait sans # le moindre message. print(f" ⚠️ GPX illisible ({gpx_file}) : {e}") m_day.get_root().html.add_child(folium.Element('
🏠 Accueil
' + get_journal_ui())) if current_gpx_filename: m_day.get_root().html.add_child(folium.Element(f'
📥 GPX
')) # REGROUPEMENT PHOTOS day_p = df_p[df_p['Jour'] == jour].copy() if not day_p.empty: tol = 0.0002 while not day_p.empty: ref = day_p.iloc[0] masque = (np.abs(day_p['Latitude'] - ref['Latitude']) < tol) & (np.abs(day_p['Longitude'] - ref['Longitude']) < tol) groupe = day_p[masque] fichiers = groupe['Fichier'].tolist() nb = len(fichiers) slides = "".join([ f'
' f'
{txt(p.strip())}
' f'' f'{txt(p.strip())}
' for idx, p in enumerate(fichiers)]) btns = f'
1/{nb}
' if nb > 1 else "" media_coords.append([ref['Latitude'], ref['Longitude']]) folium.Marker(location=[ref['Latitude'], ref['Longitude']], popup=folium.Popup(f'
{slides}
{btns}
', max_width="100%"), icon=create_pin("#FF3B30", "camera" if nb==1 else "images")).add_to(marker_cluster) day_p = day_p[~masque] # Vidéos day_v = df_v[df_v['Jour'] == jour] for v_name, group in day_v.groupby('Fichier'): pts_v = group[['Latitude', 'Longitude']].values.tolist() base_name = os.path.splitext(v_name.strip())[0] clean_id = re.sub(r'[^a-zA-Z0-9]', '', base_name) video_id = f"vid_{clean_id}" desktop_file = f"../videos/{url(base_name)}.mp4" mobile_file = f"../videos/{url(base_name)}_mobile.mp4" media_coords.append(pts_v[0]) # Ajout 'autoplay muted' pour le lancement au clic # Ajout 'preload=none' pour ne pas charger les autres # --- BLOC VIDÉO FIABLE (Défaut = Léger, Upgrade manuel) --- v_pop = ( f'
' f'
' f'{txt(v_name.strip())}' f'
' # Le bouton 720p est actif par défaut (cohérent avec le fichier chargé) f'720p' # data-hevc : ce palier est en H.265, il sera grisé si le # navigateur ne sait pas le décoder. f'1080p' f'
' f'
' # 1. On charge par défaut le fichier MOBILE (léger) pour garantir la lecture partout. # 2. preload="none" : Indispensable pour que la page charge vite. # 3. Pas d'autoplay. f'' # Note discrète affichée uniquement si le 1080p est indisponible f'
' f'Le 1080p est encodé en H.265/HEVC, que ce navigateur ne sait pas décoder. ' f'La lecture en 720p fonctionne normalement ; pour la haute définition, ' f'utilise Safari, Chrome ou Edge.' f'
' f'
' ) folium.Marker(location=pts_v[0], popup=folium.Popup(v_pop, max_width="100%"), icon=create_pin("#007AFF", "play")).add_to(marker_cluster) # Cadrage : la trace GPX en priorité, sinon la position des médias. # Sans ce repli, un jour sans GPX (mais avec photos) s'ouvrait sur une # mappemonde centrée au large de l'Afrique. cadrage = day_coords if day_coords else media_coords if cadrage: m_day.fit_bounds(cadrage, padding=(50, 50)) all_global_coords.extend(media_coords) folium.LayerControl(position='topright').add_to(m_day) plugins.Fullscreen(position='topright').add_to(m_day) m_day.save(os.path.join(html_output_dir, file_name)) # --- FINALISATION --- if all_global_coords: m_global.fit_bounds(all_global_coords, padding=(50, 50)) m_mini.fit_bounds(all_global_coords, padding=(20, 20)) sidebar_html = f'
⬅ Retour
PAR JOURS
{sidebar_links_html}
' m_global.get_root().html.add_child(folium.Element(sidebar_html + get_journal_ui())) folium.LayerControl().add_to(m_global) m_global.save(os.path.join(base_dir, "index.html")) m_mini.save(os.path.join(base_dir, "mini.html"))