Files
RoadTripsGenerator/Scripts/genere_carte.py

341 lines
18 KiB
Python

#!/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
# --- 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']
# --- 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>
// --- GESTION DES BOUTONS 720p / 1080p ---
function changeQuality(videoId, newSrc, btnElement) {
var video = document.getElementById(videoId);
if (video.src.includes(newSrc)) 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')) {
var video = node.querySelector('video');
if (video) {
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; }
.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):
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 = []
current_gpx_filename = None
if os.path.exists(routes_dir):
for gpx_file in os.listdir(routes_dir):
if gpx_file.startswith(day_str) and gpx_file.endswith('.gpx'):
current_gpx_filename = gpx_file
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)
except: pass
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/{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"};"><div class="file-header">{p.strip()}</div><a href="../photos/{p.strip()}" target="_blank"><img src="../photos/{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 ""
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/{base_name}.mp4"
mobile_file = f"../videos/{base_name}_mobile.mp4"
# 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="{v_name.strip()}">{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>'
f'<span class="quality-btn" 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}">' # <-- On pointe directement sur le fichier léger
f'</video></div>'
)
folium.Marker(location=pts_v[0], popup=folium.Popup(v_pop, max_width="100%"), icon=create_pin("#007AFF", "play")).add_to(marker_cluster)
if day_coords: m_day.fit_bounds(day_coords, padding=(50, 50))
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"))