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:
1 parent
5a04f159ca
commit
97b936fd3e
17 files changed
+2042
-829
No files matched your search
+448
-401
@@ -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)">×</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)">❮</button><button class="nav-btn next" onclick="moveSlide(this, 1)">❯</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()
|
||||
Reference in new issue
Block a user