311 lines
17 KiB
Python
311 lines
17 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; // On regarde si c'était en lecture
|
|
|
|
video.src = newSrc;
|
|
|
|
// On attend que les métadonnées soient prêtes pour remettre le temps
|
|
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();
|
|
|
|
// Mise à jour visuelle des boutons
|
|
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');
|
|
}
|
|
}
|
|
// ----------------------------------------
|
|
|
|
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)">×</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)">❮</button><button class="nav-btn next" onclick="moveSlide(this, 1)">❯</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
|
|
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">'
|
|
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>'
|
|
|
|
f'<video id="{video_id}" style="width:100%; max-height:70vh; border-radius:8px; background:black;" '
|
|
f'controls playsinline webkit-playsinline autoplay muted preload="none" '
|
|
f'src="{mobile_file}">'
|
|
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"))
|
|
|
|
|
|
|