Phase 1 : moteur Python et commande rtg
- Paquet roadtripsgenerator (pyproject.toml), commande rtg : generer, etat, analyser, compresser, accueil, migrer - Réglages site.toml / voyage.toml (anciens mymaps.txt et moto.txt encore lus) ; qualité vidéo en trois crans - Sources laissées en place : traces découpées par journée d'après leurs horodatages (coupure à 4 h) ou la date du nom de fichier, photos copiées, HEIC converties en JPEG, position déduite de la trace pour les photos sans GPS - extraction_infos.sh et compress.sh portés en Python (plus de bash) ; sorties vidéo écrites sous un nom temporaire ; positions des rushs inchangés reprises du dernier export ; export vidéo conservé quand le disque des rushs est débranché - Pages, accueil et carte globale repris dans le paquet : sortie identique aux scripts (123 fichiers comparés sur 7 voyages) - Tests pytest sur un voyage d'exemple fabriqué à la volée 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
97b936fd3e
commit
f0e1c2dfce
23 files changed
+3083
-1
No files matched your search
+504
@@ -0,0 +1,504 @@
|
||||
"""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
|
||||
|
||||
Appelé par `rtg generer` (rtg.site).
|
||||
|
||||
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/.
|
||||
|
||||
Réglages (site.toml) : adresse_publique, pour les liens du KML.
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from urllib.parse import quote
|
||||
|
||||
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
|
||||
|
||||
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
|
||||
# Fixés par generer() : racine du site et adresse publique.
|
||||
CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"}
|
||||
ASSETS = ("carte.css", "carte.js")
|
||||
|
||||
COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E']
|
||||
COLONNES = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
|
||||
TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
|
||||
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)
|
||||
|
||||
# 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 encodé pour un src/href (espaces, &, #…)."""
|
||||
return quote(str(chemin))
|
||||
|
||||
|
||||
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('.')
|
||||
|
||||
|
||||
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 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 = CONTEXTE["racine"] or 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)
|
||||
|
||||
|
||||
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, '/')
|
||||
|
||||
|
||||
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 = CONTEXTE["site_url"] + quote(os.path.relpath(base_dir, racine_site(base_dir)).replace(os.sep, '/')) + '/'
|
||||
titre = nom_voyage.replace('-', ' ').replace('_', ' ')
|
||||
|
||||
# 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
|
||||
|
||||
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)
|
||||
|
||||
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)")
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
class Journal:
|
||||
"""Récit voyage.md converti en HTML, avec deux raccourcis résolus ici :
|
||||
|
||||
[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
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
def _signaler(self, message):
|
||||
if message not in self._signale:
|
||||
self._signale.add(message)
|
||||
print(f" ⚠️ Journal : {message}")
|
||||
|
||||
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>')
|
||||
|
||||
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>'
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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(self.routes_dir, gpx_file), 'r', encoding='utf-8') as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
except Exception as e:
|
||||
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))
|
||||
|
||||
|
||||
def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telechargement):
|
||||
"""Carte d'une journée avec photos et vidéos.
|
||||
|
||||
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 = [], []
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
return m_day, media_coords
|
||||
|
||||
|
||||
def generer(base_dir, racine=None, site_url=None):
|
||||
"""Pages d'un voyage. `racine` : racine du site (bouton « Retour »)."""
|
||||
CONTEXTE["racine"] = racine
|
||||
if site_url:
|
||||
CONTEXTE["site_url"] = site_url.rstrip('/') + '/'
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
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é).")
|
||||
return False
|
||||
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/")
|
||||
return True
|
||||
Reference in new issue
Block a user