Files
RoadTripsGenerator/rtg/pages.py
T
FabriceandClaude Opus 5.5 71779f6f53 Interface : corrections à la souris et accès protégé
Onglet « Carte et corrections » : glisser une photo ou une vidéo, placer
les médias sans position, masquer des zones de trace (pour un voyage, ou
zone privée pour tous). Corrections notées dans voyage.toml ([positions],
[corrections]) ou site.toml ([confidentialite]), appliquées par les pages ;
les originaux ne changent pas. L'extraction garde les médias datés sans
position pour qu'on puisse les placer.

Accès : clé d'accès aléatoire à chaque lancement, session par cookie
HttpOnly/SameSite=Strict, mot de passe facultatif (rtg mot-de-passe,
empreinte scrypt hors du site, blocage après 5 échecs), accès réseau
seulement avec mot de passe (--distant), HTTPS possible (--certificat,
--cle), Content-Security-Policy et interdiction des cadres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 14:35:43 +00:00

520 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from rtg import corrections
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, positions=None):
"""CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.
`positions` : {fichier: (lat, lon)} placés à la main, qui l'emportent."""
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)
for nom, (lat, lon) in (positions or {}).items():
lignes = df['Fichier'].astype(str).str.strip() == nom
df.loc[lignes, 'Latitude'] = lat
df.loc[lignes, 'Longitude'] = lon
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, cfg=None):
self.base_dir = base_dir
self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage"
self.routes_dir = os.path.join(base_dir, "routes")
cfg = cfg or {}
self.zones = corrections.masques(cfg)
pos = corrections.positions(cfg)
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"), pos)
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"), pos)
self.points_masques = 0
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:
self.points_masques += corrections.filtrer_piste(track, self.zones)
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, cfg=None):
"""Pages d'un voyage. `racine` : racine du site (bouton « Retour ») ;
`cfg` : réglages du voyage (corrections faites dans l'interface)."""
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, cfg)
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()
if v.points_masques:
print(f" 🙈 {v.points_masques} point(s) de trace retiré(s) par les zones masquées")
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