Files
RoadTripsGenerator/Scripts/genere_carte.py
T
2026-08-17 23:50:11 +02:00

460 lines
24 KiB
Python
Executable File

#!/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"""<div style="position: relative; height: 40px; width: 24px;">
<div style="background-color: {color}; width: 24px; height: 24px; border-radius: 50%; border: 2px solid white;
display: flex; align-items: center; justify-content: center; color: white; font-size: 12px;
position: absolute; top: 0; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.4);">
<i class="fa fa-{icon_name}"></i>
</div>
<div style="width: 3px; height: 18px; background-color: {color}; position: absolute; top: 22px; left: 10.5px; z-index: 1;"></div>
</div>"""
return folium.DivIcon(html=html_code, icon_anchor=(12, 40))
def inject_common_assets(m):
custom_js = """
<script>
// --- DÉTECTION HEVC ---
// Stratégie hybride : le 720p (H.264) est chargé par défaut et se lit
// partout. Seul le 1080p est en HEVC. Si le navigateur ne sait pas décoder
// le HEVC (Firefox principalement), on neutralise le bouton 1080p —
// la vidéo reste lisible, seule l'option haute qualité disparaît.
var HEVC_OK = (function () {
try {
var v = document.createElement('video');
var t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L120.B0"');
return t === 'probably' || t === 'maybe';
} catch (e) { return false; }
})();
function applyCodecSupport(popup) {
if (HEVC_OK) return;
var btns = popup.querySelectorAll('.quality-btn[data-hevc="1"]');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.add('qbtn-disabled');
btns[i].title = "Le 1080p est encodé en HEVC, que ce navigateur ne sait pas décoder. Essaie Safari, Chrome ou Edge.";
}
var note = popup.querySelector('.hevc-note');
if (note) note.style.display = 'block';
}
// --- GESTION DES BOUTONS 720p / 1080p ---
function changeQuality(videoId, newSrc, btnElement) {
if (btnElement.classList.contains('qbtn-disabled')) return;
var video = document.getElementById(videoId);
// On compare les NOMS DE FICHIER, pas les chemins : video.src est une
// URL absolue résolue par le navigateur, alors que newSrc est relatif
// ("../videos/X.mp4"). L'ancien test `video.src.includes(newSrc)` ne
// pouvait donc jamais être vrai, et recliquer sur la qualité déjà
// active rechargeait la vidéo en repartant du début.
var nomActuel = decodeURIComponent(video.src).split('/').pop();
var nomVoulu = decodeURIComponent(newSrc).split('/').pop();
if (nomActuel === nomVoulu) return;
var savedTime = video.currentTime;
var wasPlaying = !video.paused;
video.src = newSrc;
video.onloadedmetadata = function() {
this.currentTime = savedTime;
if (wasPlaying) {
var playPromise = this.play();
if (playPromise !== undefined) {
playPromise.catch(error => { console.log("Lecture bloquée"); });
}
}
this.onloadedmetadata = null;
};
video.load();
var container = btnElement.parentElement;
var buttons = container.getElementsByClassName('quality-btn');
for (var i = 0; i < buttons.length; i++) {
buttons[i].classList.remove('active-720', 'active-1080');
buttons[i].style.opacity = "0.6";
}
btnElement.style.opacity = "1";
if (btnElement.innerText.includes("720p")) {
btnElement.classList.add('active-720');
} else {
btnElement.classList.add('active-1080');
}
}
// --- AUTOPLAY INTELLIGENT (DÉTECTION D'OUVERTURE POPUP) ---
// Cet observateur surveille l'apparition des popups.
// Dès qu'une popup s'ouvre, il trouve la vidéo et lance la lecture.
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
// Si l'élément ajouté est une popup Leaflet
if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) {
applyCodecSupport(node);
var video = node.querySelector('video');
if (video) {
// Filet de sécurité : si le 1080p HEVC échoue malgré la
// détection, on retombe automatiquement sur le 720p H.264.
video.addEventListener('error', function () {
var fb = this.getAttribute('data-fallback');
if (fb && this.src.indexOf(fb) === -1) { this.src = fb; this.load(); }
});
console.log("🎬 Popup ouverte : Lancement de la vidéo...");
var playPromise = video.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
console.log("Autoplay bloqué par le navigateur (interaction requise ?)", error);
});
}
}
}
});
});
});
// On lance l'espion dès que la page est chargée
document.addEventListener("DOMContentLoaded", function() {
observer.observe(document.body, { childList: true, subtree: true });
});
// ----------------------------------------
function toggleJournal(open) {
var panel = document.getElementById('journal-panel');
if (open) {
panel.classList.add('open');
localStorage.setItem('journalOpen', 'true');
} else {
panel.classList.remove('open');
localStorage.setItem('journalOpen', 'false');
}
}
window.addEventListener('DOMContentLoaded', function() {
if (localStorage.getItem('journalOpen') === 'true') {
var panel = document.getElementById('journal-panel');
if (panel) panel.classList.add('open');
}
});
function moveSlide(btn, step) {
var container = btn.parentElement.querySelector('.slides');
var slides = container.querySelectorAll('.slide');
var activeIndex = Array.from(slides).findIndex(s => s.style.display !== 'none');
if (activeIndex === -1) activeIndex = 0;
slides[activeIndex].style.display = 'none';
var nextIndex = (activeIndex + step + slides.length) % slides.length;
slides[nextIndex].style.display = 'block';
btn.parentElement.querySelector('.slide-counter').innerText = (nextIndex + 1) + '/' + slides.length;
}
</script>
"""
custom_css = """
<style>
.header-row {
display: flex; justify-content: space-between; align-items: center;
background: #f0f0f0; padding: 5px 10px; border-radius: 4px; margin-bottom: 5px;
}
.file-name {
font-size: 11px; font-family: monospace; font-weight: bold; color: #333;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
}
.quality-controls { display: flex; gap: 6px; }
.quality-btn {
padding: 3px 8px; border-radius: 12px; font-size: 10px; font-weight: bold; cursor: pointer;
border: 1px solid #ccc; background: #fff; opacity: 0.6; transition: 0.2s; font-family: sans-serif;
text-transform: uppercase;
}
.quality-btn:hover { opacity: 1; background: #eee; }
.active-720 { background-color: #2ECC71 !important; color: white; border-color: #27ae60 !important; opacity: 1 !important; }
.active-1080 { background-color: #007AFF !important; color: white; border-color: #0056b3 !important; opacity: 1 !important; }
.qbtn-disabled {
opacity: 0.35 !important; cursor: not-allowed !important;
text-decoration: line-through; background: #eee !important;
}
.qbtn-disabled:hover { opacity: 0.35 !important; background: #eee !important; }
.hevc-note {
display: none; margin-top: 6px; font-family: sans-serif; font-size: 11px;
color: #8a6d3b; background: #fcf8e3; border-radius: 6px; padding: 6px 10px;
text-align: left;
}
.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; }
.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; }
#journal-panel {
position: fixed; top: 0; right: -100%; width: 400px; height: 100%;
background: white; z-index: 10005; transition: 0.4s;
box-shadow: -5px 0 15px rgba(0,0,0,0.2); padding: 25px;
overflow-y: auto; font-family: sans-serif;
}
#journal-panel.open { right: 0; }
@media (max-width: 600px) { #journal-panel { width: 85%; } }
.close-btn { position: absolute; top: 15px; right: 15px; font-size: 24px; cursor: pointer; border: none; background: none; }
.slider-container { position: relative; width: calc(85vw - 60px); max-width: 800px; text-align: center; min-height: 200px; }
.slide img { max-width: 100%; max-height: 65vh; border-radius: 8px; display: block; margin: auto; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.6); color: white; border: 2px solid white; padding: 12px; cursor: pointer; border-radius: 50%; z-index: 10; font-size: 18px; }
.prev { left: -10px; } .next { right: -10px; }
.slide-counter { margin-top: 8px; font-size: 13px; font-weight: bold; color: #333; font-family: sans-serif; }
</style>
"""
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"""
<div id="journal-panel"><button class="close-btn" onclick="toggleJournal(false)">&times;</button><div class="journal-content">{journal_html}</div></div>
<div style="position: fixed; bottom: 25px; right: 15px; z-index: 10000; display: flex; flex-direction: column; align-items: flex-end; gap: 10px;">
<button onclick="toggleJournal(true)" style="background: #FFD700; border: 2px solid white; padding: 12px 20px; border-radius: 50px; font-weight: bold; font-size: 14px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3); font-family:sans-serif;">📖 Journal</button>
</div>
"""
# --- 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'<a href="html/{file_name}" target="_top" style="display:block; margin-bottom:8px; padding:12px; background:{color_day}; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; font-family:sans-serif;">{day_str}</a>'
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"<div style='text-align:center; font-family:sans-serif;'><b>📅 {day_str}</b><br><br><a href='html/{file_name}' target='_top' style='background:{color_day}; color:white; padding:8px 12px; border-radius:4px; text-decoration:none; font-weight:bold;'>Détails</a></div>"
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('<div style="position: fixed; top: 15px; left: 15px; z-index: 10001;"><a href="../index.html" target="_top" style="text-decoration: none; background: #333; color: white; padding: 12px 20px; border-radius: 10px; font-family: sans-serif; font-weight: bold; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid white; display: block;">🏠 Accueil</a></div>' + get_journal_ui()))
if current_gpx_filename:
m_day.get_root().html.add_child(folium.Element(f'<div style="position: fixed; bottom: 85px; right: 15px; z-index: 10001;"><a href="../routes/{url(current_gpx_filename)}" download style="text-decoration: none; background: #28a745; color: white; padding: 12px 20px; border-radius: 50px; font-family: sans-serif; font-weight: bold; box-shadow: 0 4px 15px rgba(0,0,0,0.4); border: 2px solid white; display: block;">📥 GPX</a></div>'))
# 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'<div class="slide" style="display:{"block" if idx == 0 else "none"};">'
f'<div class="file-header">{txt(p.strip())}</div>'
f'<a href="../photos/{url(p.strip())}" target="_blank">'
f'<img src="../photos/{url(p.strip())}" alt="{txt(p.strip())}" loading="lazy"></a></div>'
for idx, p in enumerate(fichiers)])
btns = f'<button class="nav-btn prev" onclick="moveSlide(this, -1)">&#10094;</button><button class="nav-btn next" onclick="moveSlide(this, 1)">&#10095;</button><div class="slide-counter">1/{nb}</div>' if nb > 1 else ""
media_coords.append([ref['Latitude'], ref['Longitude']])
folium.Marker(location=[ref['Latitude'], ref['Longitude']], popup=folium.Popup(f'<div class="slider-container"><div class="slides">{slides}</div>{btns}</div>', 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'<div style="width:calc(85vw - 40px); max-width:1000px; text-align:center;">'
f'<div class="header-row">'
f'<span class="file-name" title="{txt(v_name.strip())}">{txt(v_name.strip())}</span>'
f'<div class="quality-controls">'
# Le bouton 720p est actif par défaut (cohérent avec le fichier chargé)
f'<span class="quality-btn active-720" onclick="changeQuality(\'{video_id}\', \'{mobile_file}\', this)">720p</span>'
# data-hevc : ce palier est en H.265, il sera grisé si le
# navigateur ne sait pas le décoder.
f'<span class="quality-btn" data-hevc="1" onclick="changeQuality(\'{video_id}\', \'{desktop_file}\', this)">1080p</span>'
f'</div>'
f'</div>'
# 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'<video id="{video_id}" style="width:100%; max-height:70vh; border-radius:8px; background:black;" '
f'controls playsinline webkit-playsinline preload="none" '
f'src="{mobile_file}" data-fallback="{mobile_file}">' # <-- 720p H.264 : lisible partout
f'</video>'
# Note discrète affichée uniquement si le 1080p est indisponible
f'<div class="hevc-note">'
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'</div>'
f'</div>'
)
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'<div style="position: fixed; top: 15px; left: 15px; width: 160px; max-height: 85vh; overflow-y: auto; background: rgba(255,255,255,0.9); z-index: 9999; padding: 12px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.2);"><a href="../" target="_top" style="display:block; margin-bottom:15px; padding:12px; background:#444; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; border: 2px solid #666; font-family:sans-serif;">⬅ Retour</a><div style="border-top: 1px solid #ccc; margin-bottom: 10px; padding-top: 10px; font-family:sans-serif; font-size:12px; font-weight:bold; color:#666; text-align:center;">PAR JOURS</div>{sidebar_links_html}</div>'
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"))