#!/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"""
"""
# --- 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"
' 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'