Refonte : templates, balades, photos web, aperçus, carte globale

- Mise en page sortie du Python : Scripts/templates/ (Jinja2 + CSS/JS
  communs copiés dans assets/ de chaque voyage)
- Voyages rangés dans un sous-dossier (Balades/…) : rubriques sur
  l'accueil, 2 plus récents par rubrique, pages voyages.html et
  balades.html
- prepare_web.py : photos allégées (photos_web/) et images d'aperçu
  des vidéos (apercus/)
- Voyage d'une seule journée : la page du voyage est la carte du jour
- Carte globale de l'accueil (OpenStreetMap) : voyages du site + cartes
  Google My Maps récupérées (mymaps.txt, _mymaps/), moto par voyage
  (moto.txt)
- KML par voyage pour My Maps
- compress.sh : réencode une sortie illisible (encodage interrompu) ;
  repli automatique sur l'autre qualité dans la page
- Corrections : CSV vide (voyage sans photo), racine de genere_site.sh,
  fichiers ._ de macOS
- README réécrit, commentaires allégés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-09 11:08:01 +00:00
1 parent 5a04f159ca
commit 97b936fd3e
17 files changed
+2042 -829

No files matched your search

+448 -401
View File
@@ -1,459 +1,506 @@
#!/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
"""Génère les pages d'un voyage.
index.html carte générale : traces du voyage, liens par jour
mini.html miniature affichée sur la page d'accueil du site
html/carte_AAAA-MM-JJ.html une carte par jour, avec photos et vidéos
<voyage>_complet.gpx toutes les traces du voyage réunies
<voyage>.kml le voyage en une seule trace simplifiée (< 5 Mo),
à importer dans Google My Maps
assets/ feuille de style et script communs
Usage : ./genere_carte.py [dossier_voyage]
Entrées : export_photos.csv et export_videos.csv (extraction_infos.sh),
routes/*.gpx (un fichier par jour, date AAAA-MM-JJ dans le nom), voyage.md,
et si présents photos_web/ et apercus/ (prepare_web.py).
La mise en page est dans Scripts/templates/.
Variables : RT_RACINE=chemin racine du site, cible du bouton « Retour »
(sinon : premier dossier parent qui contient un index.html)
SITE_URL=https://roadtrip.ouaibe.fr/ adresse publique, pour les liens du KML
"""
import copy
import hashlib
import html
import json
import os
import re
import shutil
import sys
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)
import folium
import gpxpy
import markdown2
import numpy as np
import pandas as pd
from folium import plugins
from folium.plugins import MarkerCluster
from jinja2 import Environment, FileSystemLoader
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)
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
ASSETS = ("carte.css", "carte.js")
COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E']
noms_colonnes = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
COLONNES = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
# --- É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.
SITE_URL = os.environ.get("SITE_URL", "https://roadtrip.ouaibe.fr/").rstrip('/') + '/'
KML_MAX_OCTETS = 4_500_000 # My Maps refuse les fichiers de plus de 5 Mo
KML_TOLERANCE_M = 10 # simplification de départ (écart toléré, en mètres)
KML_COULEUR = "ff3357ff" # #FF5733 au format KML (aabbggrr)
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('.')
# Photos prises à moins de ~20 m l'une de l'autre : un seul marqueur, en diaporama.
TOLERANCE_GROUPE_PHOTOS = 0.0002
env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True,
trim_blocks=True, lstrip_blocks=True)
F = env.get_template("fragments.html").module
def url(chemin):
"""Chemin sûr pour un attribut src/href."""
return html.escape(quote(chemin))
"""Chemin encodé pour un src/href (espaces, &, #…)."""
return quote(str(chemin))
def txt(valeur):
"""Texte sûr pour l'affichage."""
return html.escape(str(valeur))
def est_sidecar(nom):
"""Fichiers cachés et « ._nom » AppleDouble que macOS dépose sur les
disques non-HFS : ce ne sont pas des médias."""
return nom.startswith('.')
# --- 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')
def bornes(points, marge=0.02, robuste=True):
"""Rectangle de cadrage [[sud, ouest], [nord, est]] et nombre de points écartés.
Avec robuste=True, on écarte les points hors de [Q1 - 3·IQR, Q3 + 3·IQR] :
le premier échantillon GPS d'une GoPro est parfois la position de la session
précédente, et un seul point aberrant suffit à dézoomer sur toute l'Europe.
À désactiver pour les traces GPX, continues par construction : le filtre y
rognerait de vrais morceaux d'itinéraire.
"""
if not points:
return None, 0
def quartiles(v):
v = sorted(v)
n = len(v)
return (v[0], v[-1]) if n < 4 else (v[n // 4], v[(3 * n) // 4])
gardes = points
if robuste:
la1, la3 = quartiles([p[0] for p in points])
lo1, lo3 = quartiles([p[1] for p in points])
# Plancher de tolérance : sur une balade très locale l'IQR est minuscule.
ela, elo = max((la3 - la1) * 3, 0.05), max((lo3 - lo1) * 3, 0.05)
gardes = [p for p in points
if la1 - ela <= p[0] <= la3 + ela and lo1 - elo <= p[1] <= lo3 + elo] or points
lats = [p[0] for p in gardes]
lons = [p[1] for p in gardes]
dla = max((max(lats) - min(lats)) * marge, 0.005)
dlo = max((max(lons) - min(lons)) * marge, 0.005)
return ([[min(lats) - dla, min(lons) - dlo], [max(lats) + dla, max(lons) + dlo]],
len(points) - len(gardes))
def lire_csv(chemin):
"""CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour."""
vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour'])
# Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire.
if not os.path.exists(chemin) or os.path.getsize(chemin) == 0:
return vide
df = pd.read_csv(chemin, names=COLONNES, header=None)
df = df[df['Latitude'].notnull() & df['Longitude'].notnull()]
df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy()
if df.empty:
return vide
df['DT'] = pd.to_datetime(df['Date_Heure'].astype(str).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; }
})();
def racine_site(base_dir):
"""Dossier racine du site : RT_RACINE, sinon le premier parent qui contient
un index.html. Un voyage peut être rangé un niveau plus bas
(Balades/Aisne-06-2026) : on ne peut pas supposer le parent direct."""
racine = os.environ.get('RT_RACINE')
if racine:
return os.path.abspath(racine)
parent = os.path.dirname(base_dir)
for _ in range(3):
if os.path.exists(os.path.join(parent, 'index.html')):
return parent
parent = os.path.dirname(parent)
return os.path.dirname(base_dir)
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;
def lien_racine_site(base_dir):
"""Chemin relatif vers l'index.html de la racine du site."""
return os.path.relpath(os.path.join(racine_site(base_dir), 'index.html'), base_dir).replace(os.sep, '/')
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;
def exporter_kml(base_dir, nom_voyage, pistes, km):
"""<voyage>.kml pour Google My Maps : UNE ligne pour tout le voyage (les
journées mises bout à bout), avec un lien vers la page du voyage. On
ne simplifie davantage que si le fichier dépasse la taille acceptée par
My Maps (5 Mo ; pas de limite de points par ligne)."""
site = SITE_URL + quote(os.path.relpath(base_dir, racine_site(base_dir)).replace(os.sep, '/')) + '/'
titre = nom_voyage.replace('-', ' ').replace('_', ' ')
var savedTime = video.currentTime;
var wasPlaying = !video.paused;
# Simplification journée par journée (rapide sur des lignes courtes), puis
# sur la ligne entière, de plus en plus fort, seulement si elle est trop lourde.
seg = gpxpy.gpx.GPXTrackSegment()
for piste in pistes:
for segment in piste.segments:
morceau = copy.deepcopy(segment)
morceau.simplify(max_distance=KML_TOLERANCE_M)
seg.points.extend(morceau.points)
if not seg.points:
return
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();
tolerance = KML_TOLERANCE_M
while True:
coords = " ".join(f"{p.longitude:.6f},{p.latitude:.6f}" for p in seg.points)
desc = (f"{km:.0f} km<br>" if km else "") + f'<a href="{site}">Voir le voyage sur le site</a>'
kml = ('<?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2"><Document>'
f"<name>{html.escape(titre)}</name>"
f"<Placemark><name>{html.escape(titre)}</name>"
f"<description><![CDATA[{desc}]]></description>"
f"<Style><LineStyle><color>{KML_COULEUR}</color><width>4</width></LineStyle></Style>"
f"<LineString><tessellate>1</tessellate><coordinates>{coords}</coordinates></LineString>"
"</Placemark></Document></kml>\n").encode('utf-8')
if len(kml) <= KML_MAX_OCTETS or tolerance > 5000:
break
tolerance *= 1.5
seg.simplify(max_distance=tolerance)
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');
}
}
nom = f"{nom_voyage}.kml"
with open(os.path.join(base_dir, nom), 'wb') as f:
f.write(kml)
print(f"🌐 KML My Maps : {nom} (1 trace, {len(seg.points)} points, {len(kml) // 1024} Ko, "
f"simplifiée à {tolerance:.0f} m)")
// --- 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 });
});
// ----------------------------------------
def installer_assets(base_dir):
"""Copie carte.css / carte.js dans <voyage>/assets/ et renvoie une version
courte (empreinte du contenu) pour forcer le rechargement après mise à jour."""
dest = os.path.join(base_dir, 'assets')
os.makedirs(dest, exist_ok=True)
empreinte = hashlib.sha1()
for nom in ASSETS:
src = os.path.join(DOSSIER_TEMPLATES, nom)
shutil.copyfile(src, os.path.join(dest, nom))
with open(src, 'rb') as f:
empreinte.update(f.read())
return empreinte.hexdigest()[:8]
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');
}
});
class Journal:
"""Récit voyage.md converti en HTML, avec deux raccourcis résolus ici :
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; }
[Carte du jour](jour:2026-08-01) → lien vers la carte de ce jour
[Le col](media:photos/IMG_4831.jpeg) → lien vers un média du voyage
.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>
Le panneau est injecté à deux profondeurs (index.html à la racine du voyage,
cartes du jour dans html/) : un lien écrit en dur serait faux sur l'une des
deux, d'où ces marqueurs. Un jour sans carte devient du texte simple.
"""
# --- 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()
def __init__(self, base_dir, jours_valides):
self.base_dir = base_dir
self.valides = {j.strftime('%Y-%m-%d') for j in jours_valides}
self.html = ""
chemin = os.path.join(base_dir, 'voyage.md')
if os.path.exists(chemin):
with open(chemin, 'r', encoding='utf-8') as f:
self.html = markdown2.markdown(f.read())
self._signale = set() # chaque problème n'est signalé qu'une fois
# --- 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)
def _signaler(self, message):
if message not in self._signale:
self._signale.add(message)
print(f" ⚠️ Journal : {message}")
m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, control_scale=False, attribution_control=False)
def rendu(self, prefixe_cartes, prefixe_media):
def jour(m):
date, texte = m.group(1), m.group(2)
if date not in self.valides:
self._signaler(f"lien vers {date}, mais aucune carte pour ce jour")
return texte
# Le fragment #jour-… ne correspond à aucun id : c'est carte.js qui
# le lit pour faire défiler le panneau jusqu'à l'ancre.
return (f'<a id="ancre-{date}" href="{prefixe_cartes}carte_{date}.html#jour-{date}" '
f'target="_top" class="lien-jour">{texte}</a>')
all_global_coords, sidebar_links_html = [], ""
def media(m):
chemin, texte = m.group(1).lstrip('/'), m.group(2)
if not os.path.exists(os.path.join(self.base_dir, chemin)):
self._signaler(f"média introuvable -> {chemin}")
return f'<a href="{prefixe_media}{url(chemin)}" target="_blank" class="lien-media">{texte}</a>'
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>'
h = re.sub(r'<a href="jour:(\d{4}-\d{2}-\d{2})"[^>]*>(.*?)</a>', jour, self.html, flags=re.S)
h = re.sub(r'<a href="media:([^"]+)"[^>]*>(.*?)</a>', media, h, flags=re.S)
return F.journal(h)
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'):
def nouvelle_carte(entete):
m = folium.Map(tiles=None)
folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite').add_to(m)
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m)
m.get_root().header.add_child(folium.Element(str(entete)))
return m
def ajouter(carte, fragment):
carte.get_root().html.add_child(folium.Element(str(fragment)))
def epingle(couleur, icone):
return folium.DivIcon(html=str(F.epingle(couleur, icone)), icon_anchor=(12, 40))
def fichiers_gpx(routes_dir):
if not os.path.isdir(routes_dir):
return []
return sorted(f for f in os.listdir(routes_dir)
if not est_sidecar(f) and f.lower().endswith('.gpx'))
def groupes_photos(day_p, base_dir, pre):
"""Regroupe les photos proches ; renvoie [(position, html, nb_photos)]."""
groupes = []
while not day_p.empty:
ref = day_p.iloc[0]
masque = ((np.abs(day_p['Latitude'] - ref['Latitude']) < TOLERANCE_GROUPE_PHOTOS)
& (np.abs(day_p['Longitude'] - ref['Longitude']) < TOLERANCE_GROUPE_PHOTOS))
photos = []
for nom in day_p[masque]['Fichier']:
nom = str(nom).strip()
# Version allégée si prepare_web.py l'a produite ; le clic ouvre l'original.
web = os.path.exists(os.path.join(base_dir, 'photos_web', nom))
photos.append({'nom': nom,
'src': f"{pre}{'photos_web' if web else 'photos'}/{url(nom)}",
'href': f"{pre}photos/{url(nom)}"})
groupes.append(([ref['Latitude'], ref['Longitude']], F.popup_photos(photos), len(photos)))
day_p = day_p[~masque]
return groupes
def popup_video(v_name, base_dir, pre):
nom = str(v_name).strip()
base = os.path.splitext(nom)[0]
apercu = None
if os.path.exists(os.path.join(base_dir, 'apercus', base + '.jpg')):
apercu = f"{pre}apercus/{url(base)}.jpg"
return F.popup_video(nom, f"{pre}videos/{url(base)}_mobile.mp4", f"{pre}videos/{url(base)}.mp4", apercu)
def exporter_trace_globale(base_dir, pistes):
"""assets/trace.json : les journées du voyage, séparées et simplifiées à
30 m, pour la carte globale de l'accueil (genere_carte_globale.py)."""
lignes = []
for piste in pistes:
for segment in piste.segments:
morceau = copy.deepcopy(segment)
morceau.simplify(max_distance=30)
if len(morceau.points) > 1:
lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in morceau.points])
with open(os.path.join(base_dir, 'assets', 'trace.json'), 'w', encoding='utf-8') as f:
json.dump(lignes, f, separators=(',', ':'))
class Voyage:
"""Données communes à toutes les pages d'un voyage."""
def __init__(self, base_dir):
self.base_dir = base_dir
self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage"
self.routes_dir = os.path.join(base_dir, "routes")
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"))
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"))
jours_gpx = set()
for f in fichiers_gpx(self.routes_dir):
m = re.search(r'(\d{4}-\d{2}-\d{2})', f)
if m:
jours_gpx.add(pd.to_datetime(m.group(1)).date())
self.jours = sorted(set(self.df_v['Jour']) | set(self.df_p['Jour']) | jours_gpx)
self.traces = {} # jour → [(piste, points)]
self.gpx_du_jour = {} # jour → nom du fichier GPX
def lire_traces(self):
for jour in self.jours:
day_str = jour.strftime('%Y-%m-%d')
self.traces[jour] = []
# Date n'importe où dans le nom du fichier.
for gpx_file in fichiers_gpx(self.routes_dir):
if day_str not in gpx_file:
continue
try:
with open(os.path.join(routes_dir, gpx_file), 'r') as f:
with open(os.path.join(self.routes_dir, gpx_file), 'r', encoding='utf-8') 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}")
continue
self.gpx_du_jour[jour] = gpx_file
for track in gpx.tracks:
pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points]
if pts:
if not track.name:
track.name = day_str
self.traces[jour].append((track, pts))
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]
def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telechargement):
"""Carte d'une journée avec photos et vidéos.
# 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>'
pre : chemin vers la racine du voyage depuis la page (« ../ » pour
html/carte_JJ.html, « » quand la carte sert de page du voyage).
"""
day_str = jour.strftime('%Y-%m-%d')
m_day = nouvelle_carte(str(F.entete(pre, version))
+ f'<script>window.JOUR_COURANT="{day_str}";</script>')
cluster = MarkerCluster(name="Médias").add_to(m_day)
day_coords, media_coords = [], []
# 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>'
for _, pts in v.traces.get(jour, []):
day_coords.extend(pts)
folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day)
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)
ajouter(m_day, F.bouton_accueil(lien_accueil))
ajouter(m_day, journal.rendu("" if pre else "html/", pre))
if bouton_telechargement:
ajouter(m_day, bouton_telechargement)
# 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)
for pos, popup, nb in groupes_photos(v.df_p[v.df_p['Jour'] == jour].copy(), v.base_dir, pre):
media_coords.append(pos)
folium.Marker(location=pos, popup=folium.Popup(str(popup), max_width="100%"),
icon=epingle("#FF3B30", "camera" if nb == 1 else "images")).add_to(cluster)
# Vidéos : marqueur au premier point GPS du rush.
for v_name, group in v.df_v[v.df_v['Jour'] == jour].groupby('Fichier'):
pos = group[['Latitude', 'Longitude']].values.tolist()[0]
media_coords.append(pos)
folium.Marker(location=pos, popup=folium.Popup(str(popup_video(v_name, v.base_dir, pre)), max_width="100%"),
icon=epingle("#007AFF", "play")).add_to(cluster)
# Cadrage : la trace GPX en priorité, sinon la position des médias
# (sans quoi un jour sans GPX s'ouvre sur une mappemonde).
b, rejetes = bornes(day_coords or media_coords, robuste=not day_coords)
if b:
m_day.fit_bounds(b, padding=(50, 50))
if rejetes and pre:
print(f" ℹ️ {day_str} : {rejetes} point(s) GPS aberrant(s) exclu(s) du cadrage")
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"))
return m_day, media_coords
def main():
base_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
html_dir = os.path.join(base_dir, "html")
os.makedirs(html_dir, exist_ok=True)
v = Voyage(base_dir)
if not v.jours:
print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).")
sys.exit(1)
v.lire_traces()
version = installer_assets(base_dir)
journal = Journal(base_dir, v.jours)
racine = lien_racine_site(base_dir)
un_seul_jour = len(v.jours) == 1
# Trace complète du voyage. Écrite à la racine du voyage et non dans
# routes/, qui est relu à chaque génération.
gpx_complet = gpxpy.gpx.GPX()
gpx_complet.name = f"{v.nom} - trace complete"
gpx_complet.creator = "genere_carte.py"
for jour in v.jours:
gpx_complet.tracks.extend(t for t, _ in v.traces[jour])
bouton_complet, km = None, 0
if gpx_complet.tracks:
nom_fichier = f"{v.nom}_complet.gpx"
with open(os.path.join(base_dir, nom_fichier), 'w', encoding='utf-8') as f:
f.write(gpx_complet.to_xml())
nb_pts = sum(len(sg.points) for t in gpx_complet.tracks for sg in t.segments)
try:
km = gpx_complet.length_2d() / 1000.0
except Exception:
km = 0
print(f"🧭 Trace complète : {nom_fichier} ({len(gpx_complet.tracks)} étape(s), {nb_pts} points"
+ (f", {km:.0f} km" if km else "") + ")")
bouton_complet = lambda pre: F.bouton_gpx( # noqa: E731
f"{pre}{url(nom_fichier)}", "📥 Trace complète" + (f" ({km:.0f} km)" if km else ""))
exporter_kml(base_dir, v.nom, gpx_complet.tracks, km)
exporter_trace_globale(base_dir, gpx_complet.tracks)
# --- Cartes du jour (html/carte_JJ.html)
liens_jours, coords_medias = [], []
for i, jour in enumerate(v.jours):
day_str = jour.strftime('%Y-%m-%d')
file_name = f'carte_{day_str}.html'
liens_jours.append({'libelle': day_str, 'lien': f'html/{file_name}',
'couleur': COULEURS_JOURS[i % len(COULEURS_JOURS)]})
if un_seul_jour:
# La carte du jour est aussi la page du voyage : son bouton mène à
# l'accueil du site, et on y télécharge la trace complète.
accueil, bouton = "../" + racine, bouton_complet("../") if bouton_complet else None
else:
gpx = v.gpx_du_jour.get(jour)
accueil = "../index.html"
bouton = F.bouton_gpx(f"../routes/{url(gpx)}", "📥 GPX") if gpx else None
m_day, medias = carte_du_jour(v, jour, version, journal, "../", accueil, bouton)
coords_medias.extend(medias)
m_day.save(os.path.join(html_dir, file_name))
# --- Miniature pour l'accueil du site
coords_traces = [p for j in v.jours for _, pts in v.traces[j] for p in pts]
cadrage = coords_traces or coords_medias
b, rejetes = bornes(cadrage, robuste=not coords_traces)
m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False,
control_scale=False, attribution_control=False)
for i, jour in enumerate(v.jours):
for _, pts in v.traces[jour]:
folium.PolyLine(pts, color=COULEURS_JOURS[i % len(COULEURS_JOURS)],
weight=3, opacity=0.8).add_to(m_mini)
if b:
m_mini.fit_bounds(b, padding=(20, 20))
m_mini.save(os.path.join(base_dir, "mini.html"))
# --- Page du voyage (index.html)
if un_seul_jour:
# Une seule journée : la page du voyage est directement la carte du
# jour, avec photos et vidéos (pas d'étape intermédiaire inutile).
m_index, _ = carte_du_jour(v, v.jours[0], version, journal, "", racine,
bouton_complet("") if bouton_complet else None)
else:
m_index = nouvelle_carte(F.entete("", version))
for i, jour in enumerate(v.jours):
day_str = jour.strftime('%Y-%m-%d')
couleur = COULEURS_JOURS[i % len(COULEURS_JOURS)]
popup = F.popup_trace(day_str, couleur, f'html/carte_{day_str}.html')
for _, pts in v.traces[jour]:
folium.PolyLine(pts, color=couleur, weight=7, opacity=0.8,
popup=folium.Popup(str(popup), max_width=200)).add_to(m_index)
if b:
source = "traces GPX" if coords_traces else "positions des médias"
print(f"🗺️ Cadrage général d'après {len(cadrage)} point(s) ({source})"
+ (f", {rejetes} aberrant(s) exclu(s)" if rejetes else ""))
m_index.fit_bounds(b, padding=(50, 50))
if bouton_complet:
ajouter(m_index, bouton_complet(""))
ajouter(m_index, F.barre_jours(racine, liens_jours))
ajouter(m_index, journal.rendu("html/", ""))
folium.LayerControl().add_to(m_index)
m_index.save(os.path.join(base_dir, "index.html"))
print(f"✅ {v.nom} : {len(v.jours)} jour(s) → index.html"
+ (" (carte du jour directement)" if un_seul_jour else "") + ", mini.html, html/")
if __name__ == "__main__":
main()