- 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
380 lines
16 KiB
Python
380 lines
16 KiB
Python
"""Carte globale du site (carte_globale.html), affichée en grand sur l'accueil.
|
||
|
||
Fond OpenStreetMap (ou satellite Esri), sans Google. Elle réunit, en calques
|
||
que l'on peut afficher ou masquer :
|
||
- le contenu récupéré de cartes Google My Maps (anciennes balades et
|
||
voyages), avec leurs couleurs d'origine, un calque par calque My Maps ;
|
||
- les voyages du site (rubrique Voyages, pas les balades du site), chaque
|
||
trace menant à la page du voyage.
|
||
|
||
Récupération des cartes My Maps :
|
||
- réglage carte_globale.mymaps de site.toml (ou ancien mymaps.txt) : liens de
|
||
cartes My Maps partagées « Tous les utilisateurs disposant du lien ». Le KML est téléchargé UNE fois dans _mymaps/<id>.kml ; ensuite
|
||
la carte ne dépend plus de Google. MYMAPS_MAJ=1 pour le récupérer à nouveau
|
||
après avoir modifié la carte Google.
|
||
- ou tout export .kml / .kmz déposé à la main dans _mymaps/.
|
||
|
||
Appelé par rtg.accueil.
|
||
"""
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import urllib.request
|
||
import zipfile
|
||
import xml.etree.ElementTree as ET
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
import folium
|
||
import gpxpy
|
||
from folium import plugins
|
||
from jinja2 import Environment, FileSystemLoader
|
||
from markupsafe import Markup, escape
|
||
|
||
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
|
||
TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||
DOSSIER_MYMAPS = "_mymaps"
|
||
SORTIE = "carte_globale.html"
|
||
|
||
# Une couleur par voyage, du plus récent au plus ancien.
|
||
COULEURS_VOYAGES = ['#E74C3C', '#2980B9', '#27AE60', '#8E44AD', '#F39C12',
|
||
'#16A085', '#D35400', '#2C3E50', '#C0392B', '#7F8C8D']
|
||
COULEUR_MYMAPS = '#8E44AD' # si le KML ne précise pas de couleur
|
||
# Écart toléré (m) en simplifiant les traces : à l'échelle de la France,
|
||
# 30 m sont invisibles et allègent beaucoup la page.
|
||
TOLERANCE_M = 30
|
||
|
||
env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True,
|
||
trim_blocks=True, lstrip_blocks=True)
|
||
|
||
|
||
def liens_et_lignes(texte):
|
||
"""Texte brut → HTML sûr : échappé, adresses http(s) cliquables, retours à la ligne."""
|
||
h = str(escape(texte))
|
||
h = re.sub(r'(https?://[^\s<]+)', r'<a href="\1" target="_blank" rel="noopener">\1</a>', h)
|
||
return Markup(h.replace('\n', '<br>'))
|
||
|
||
|
||
env.filters['liens_et_lignes'] = liens_et_lignes
|
||
F = env.get_template("fragments_globale.html").module
|
||
|
||
|
||
# --- Traces des voyages du site ---------------------------------------------
|
||
def eclaircir(points, ecart_m):
|
||
"""Ne garde que les points distants d'au moins ecart_m du précédent gardé
|
||
(calcul approché, rapide). Divise d'autant le travail de simplify(), lent
|
||
en Python sur les 100 000 points d'un grand voyage."""
|
||
if len(points) < 3:
|
||
return points
|
||
garde = [points[0]]
|
||
seuil = (ecart_m / 111_320.0) ** 2 # degrés², à peu près
|
||
for p in points[1:-1]:
|
||
q = garde[-1]
|
||
dlat = p.latitude - q.latitude
|
||
dlon = (p.longitude - q.longitude) * math.cos(math.radians(p.latitude))
|
||
if dlat * dlat + dlon * dlon >= seuil:
|
||
garde.append(p)
|
||
garde.append(points[-1])
|
||
return garde
|
||
|
||
|
||
def longueur_km(lignes):
|
||
total = 0.0
|
||
for l in lignes:
|
||
for (la1, lo1), (la2, lo2) in zip(l, l[1:]):
|
||
dlat = math.radians(la2 - la1)
|
||
dlon = math.radians(lo2 - lo1) * math.cos(math.radians((la1 + la2) / 2))
|
||
total += 6371.0 * math.hypot(dlat, dlon)
|
||
return total
|
||
|
||
|
||
def trace_voyage(dossier):
|
||
"""Lignes simplifiées [[lat, lon], …] d'un voyage et sa longueur en km.
|
||
|
||
Source : assets/trace.json (journées séparées, déjà simplifiées par
|
||
genere_carte.py) ; à défaut <voyage>.kml (une seule ligne : les journées
|
||
y sont reliées en ligne droite) ou <voyage>_complet.gpx (lent).
|
||
"""
|
||
nom = os.path.basename(dossier)
|
||
json_trace = os.path.join(dossier, 'assets', 'trace.json')
|
||
if os.path.exists(json_trace):
|
||
with open(json_trace, encoding='utf-8') as f:
|
||
lignes = json.load(f)
|
||
km = None
|
||
kml = os.path.join(dossier, f"{nom}.kml")
|
||
if os.path.exists(kml):
|
||
with open(kml, encoding='utf-8') as f:
|
||
m = re.search(r'(\d+) km<br>', f.read())
|
||
km = float(m.group(1)) if m else None
|
||
return lignes, km if km is not None else longueur_km(lignes)
|
||
kml = os.path.join(dossier, f"{nom}.kml")
|
||
gpx_complet = os.path.join(dossier, f"{nom}_complet.gpx")
|
||
brutes, km = [], None
|
||
if os.path.exists(kml):
|
||
with open(kml, encoding='utf-8') as f:
|
||
texte = f.read()
|
||
# Distance exacte calculée par genere_carte.py sur la trace complète
|
||
# (recalculée ici, elle compterait les sauts entre deux journées).
|
||
m = re.search(r'(\d+) km<br>', texte)
|
||
km = float(m.group(1)) if m else None
|
||
for bloc in re.findall(r'<coordinates>(.*?)</coordinates>', texte, flags=re.S):
|
||
pts = []
|
||
for c in bloc.split():
|
||
v = c.split(',')
|
||
pts.append(gpxpy.gpx.GPXTrackPoint(float(v[1]), float(v[0])))
|
||
brutes.append(pts)
|
||
elif os.path.exists(gpx_complet):
|
||
with open(gpx_complet, encoding='utf-8') as f:
|
||
gpx = gpxpy.parse(f)
|
||
brutes = [seg.points for t in gpx.tracks for seg in t.segments]
|
||
km = gpx.length_2d() / 1000.0
|
||
lignes = []
|
||
for pts in brutes:
|
||
s = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2))
|
||
s.simplify(max_distance=TOLERANCE_M)
|
||
if len(s.points) > 1:
|
||
lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in s.points])
|
||
return lignes, km if km is not None else longueur_km(lignes)
|
||
|
||
|
||
# --- Cartes My Maps ----------------------------------------------------------
|
||
def mid_de(lien):
|
||
mid = parse_qs(urlparse(lien).query).get('mid')
|
||
return mid[0] if mid else None
|
||
|
||
|
||
def telecharger_mymaps(racine, liens, mettre_a_jour=False):
|
||
"""Récupère le KML des cartes My Maps vers _mymaps/<mid>.kml (une fois ;
|
||
mettre_a_jour=True ou MYMAPS_MAJ=1 pour forcer)."""
|
||
if not liens:
|
||
return
|
||
dossier = os.path.join(racine, DOSSIER_MYMAPS)
|
||
os.makedirs(dossier, exist_ok=True)
|
||
for lien in liens:
|
||
mid = mid_de(lien)
|
||
if not mid:
|
||
print(f" ⚠️ Pas un lien My Maps (mid=… manquant) : {lien}")
|
||
continue
|
||
dest = os.path.join(dossier, f"{mid}.kml")
|
||
if os.path.exists(dest) and not mettre_a_jour and os.environ.get("MYMAPS_MAJ") != "1":
|
||
continue
|
||
url = f"https://www.google.com/maps/d/kml?forcekml=1&mid={mid}"
|
||
contenu, erreur = telecharger(url)
|
||
if contenu is not None and b"<kml" not in contenu[:3000]:
|
||
contenu, erreur = None, ("Google a renvoyé une page au lieu de la carte : est-elle partagée "
|
||
"« Tous les utilisateurs disposant du lien » ?")
|
||
if contenu is None:
|
||
etat = "copie précédente utilisée" if os.path.exists(dest) else "ignorée"
|
||
print(f" ⚠️ My Maps {mid[:10]}… non récupérée — {erreur} ({etat})")
|
||
continue
|
||
with open(dest, 'wb') as f:
|
||
f.write(contenu)
|
||
print(f" 🌐 My Maps {mid[:10]}… récupérée dans {DOSSIER_MYMAPS}/ ({len(contenu) // 1024} Ko)")
|
||
|
||
|
||
def telecharger(url):
|
||
"""Renvoie (contenu, None) ou (None, message d'erreur).
|
||
|
||
Python d'abord, puis curl : le Python de python.org sous macOS n'a souvent
|
||
pas les certificats racine (CERTIFICATE_VERIFY_FAILED), curl utilise ceux
|
||
du système.
|
||
"""
|
||
try:
|
||
with urllib.request.urlopen(url, timeout=30) as r:
|
||
return r.read(), None
|
||
except Exception as e:
|
||
erreur = str(e)
|
||
if shutil.which("curl"):
|
||
r = subprocess.run(["curl", "-sSfL", "--max-time", "60", url], capture_output=True)
|
||
if r.returncode == 0 and r.stdout:
|
||
return r.stdout, None
|
||
erreur += f" ; curl : {r.stderr.decode(errors='replace').strip() or 'code ' + str(r.returncode)}"
|
||
return None, erreur
|
||
|
||
|
||
def lire_kml(chemin):
|
||
if chemin.lower().endswith('.kmz'):
|
||
with zipfile.ZipFile(chemin) as z:
|
||
nom = next((n for n in z.namelist() if n.lower().endswith('.kml')), None)
|
||
return z.read(nom) if nom else None
|
||
with open(chemin, 'rb') as f:
|
||
return f.read()
|
||
|
||
|
||
def couleur_kml(aabbggrr):
|
||
"""aabbggrr (KML) → #RRGGBB."""
|
||
c = aabbggrr.strip().lstrip('#')
|
||
return f"#{c[6:8]}{c[4:6]}{c[2:4]}".upper() if len(c) == 8 else None
|
||
|
||
|
||
def texte_simple(description):
|
||
"""Description My Maps → texte brut (balises retirées) ; le gabarit
|
||
l'échappe et rend les liens cliquables."""
|
||
if not description:
|
||
return ""
|
||
t = re.sub(r'<(script|style)\b.*?</\1>', '', description, flags=re.I | re.S)
|
||
t = re.sub(r'<br\s*/?>', '\n', t, flags=re.I)
|
||
t = re.sub(r'<[^>]+>', '', t)
|
||
return t.strip()
|
||
|
||
|
||
def nettoyer_nom(nom):
|
||
"""« Track-Pyrenees 2014 _ aller.kmz » → « Pyrenees 2014 – aller »."""
|
||
n = re.sub(r'\.km[lz]$', '', nom.strip(), flags=re.I)
|
||
n = re.sub(r'^(Track|T)\s*-\s*', '', n, flags=re.I)
|
||
n = re.sub(r'\s+_\s+|\s*:\s+', ' – ', n)
|
||
return re.sub(r'\s{2,}', ' ', n).strip()
|
||
|
||
|
||
def resumer_description(texte):
|
||
"""Les statistiques automatiques de My Maps (« Statistiques calculées… »,
|
||
altitudes à 0) sont réduites à la distance ; une vraie description est gardée."""
|
||
if texte.startswith("Statistiques calculées"):
|
||
m = re.search(r'Distance\s*:\s*([\d\s,.]+)\s*km', texte.replace('\xa0', ' '))
|
||
return f"{m.group(1).strip()} km" if m else ""
|
||
return texte
|
||
|
||
|
||
def elements_mymaps(contenu):
|
||
"""KML → (titre du document, {nom de calque: [éléments]}).
|
||
|
||
Élément : {'type': 'ligne'|'point', 'nom', 'description', 'couleur', 'coords'}.
|
||
"""
|
||
ns = {'k': 'http://www.opengis.net/kml/2.2'}
|
||
racine = ET.fromstring(contenu)
|
||
doc = racine.find('k:Document', ns)
|
||
if doc is None:
|
||
return None, {}
|
||
|
||
# Styles : id → couleur ; les StyleMap renvoient vers le style « normal ».
|
||
styles = {}
|
||
for st in doc.iter('{http://www.opengis.net/kml/2.2}Style'):
|
||
# (pas de « or » : un élément XML sans enfant est évalué comme faux)
|
||
c = st.find('k:LineStyle/k:color', ns)
|
||
if c is None:
|
||
c = st.find('k:IconStyle/k:color', ns)
|
||
if st.get('id') and c is not None and c.text:
|
||
styles['#' + st.get('id')] = couleur_kml(c.text)
|
||
for sm in doc.iter('{http://www.opengis.net/kml/2.2}StyleMap'):
|
||
for paire in sm.findall('k:Pair', ns):
|
||
if paire.findtext('k:key', '', ns) == 'normal':
|
||
styles['#' + (sm.get('id') or '')] = styles.get(paire.findtext('k:styleUrl', '', ns))
|
||
|
||
def coords(el):
|
||
pts = []
|
||
for c in (el.findtext('k:coordinates', '', ns) or '').split():
|
||
v = c.split(',')
|
||
if len(v) >= 2:
|
||
pts.append([round(float(v[1]), 5), round(float(v[0]), 5)])
|
||
return pts
|
||
|
||
calques = {}
|
||
|
||
def visiter(noeud, calque):
|
||
for enfant in noeud:
|
||
tag = enfant.tag.split('}')[-1]
|
||
if tag == 'Folder':
|
||
visiter(enfant, enfant.findtext('k:name', calque, ns))
|
||
elif tag == 'Placemark':
|
||
base = {'nom': nettoyer_nom(enfant.findtext('k:name', '', ns)),
|
||
'description': resumer_description(texte_simple(enfant.findtext('k:description', '', ns))),
|
||
'couleur': styles.get(enfant.findtext('k:styleUrl', '', ns)) or COULEUR_MYMAPS}
|
||
for ls in enfant.iter('{http://www.opengis.net/kml/2.2}LineString'):
|
||
pts = coords(ls)
|
||
if len(pts) > 1:
|
||
calques.setdefault(calque, []).append(dict(base, type='ligne', coords=pts))
|
||
for pt in enfant.iter('{http://www.opengis.net/kml/2.2}Point'):
|
||
pts = coords(pt)
|
||
if pts:
|
||
calques.setdefault(calque, []).append(dict(base, type='point', coords=pts[0]))
|
||
|
||
titre = doc.findtext('k:name', 'My Maps', ns)
|
||
visiter(doc, titre)
|
||
return titre, calques
|
||
|
||
|
||
def simplifier(coords):
|
||
pts = [gpxpy.gpx.GPXTrackPoint(la, lo) for la, lo in coords]
|
||
seg = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2))
|
||
seg.simplify(max_distance=TOLERANCE_M)
|
||
return [[round(p.latitude, 5), round(p.longitude, 5)] for p in seg.points]
|
||
|
||
|
||
# --- Carte -------------------------------------------------------------------
|
||
def generer_carte_globale(racine, rubriques, liens_mymaps=(), mettre_a_jour=False):
|
||
"""rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier', 'moto'}]}."""
|
||
racine = os.path.abspath(racine)
|
||
telecharger_mymaps(racine, list(liens_mymaps), mettre_a_jour)
|
||
|
||
m = folium.Map(tiles=None, scrollWheelZoom=False)
|
||
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m)
|
||
folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite', show=False).add_to(m)
|
||
tous = []
|
||
resume = []
|
||
|
||
# Voyages du site : un calque par voyage, coloré, que la légende permet
|
||
# d'afficher ou de masquer ; le plus récent en tête de légende.
|
||
voyages = [v for r in rubriques for v in r['voyages']]
|
||
calques = []
|
||
for i, v in enumerate(voyages):
|
||
lignes, km = trace_voyage(v['dossier'])
|
||
if not lignes:
|
||
continue
|
||
couleur = COULEURS_VOYAGES[i % len(COULEURS_VOYAGES)]
|
||
libelle = f"{v['titre']} ({v['moto']})" if v.get('moto') else v['titre']
|
||
groupe = folium.FeatureGroup(name=str(F.legende(libelle, couleur)))
|
||
popup = str(F.popup_voyage(libelle, v['periode'], km, f"{v['chemin']}/index.html"))
|
||
for l in lignes:
|
||
folium.PolyLine(l, color=couleur, weight=4, opacity=0.85, tooltip=libelle,
|
||
popup=folium.Popup(popup, max_width=260)).add_to(groupe)
|
||
tous.extend(l)
|
||
calques.append(groupe)
|
||
for groupe in calques:
|
||
groupe.add_to(m)
|
||
resume.append(f"{len(calques)} voyage(s)")
|
||
|
||
# Cartes My Maps
|
||
dossier = os.path.join(racine, DOSSIER_MYMAPS)
|
||
fichiers = sorted(f for f in os.listdir(dossier) if f.lower().endswith(('.kml', '.kmz'))) \
|
||
if os.path.isdir(dossier) else []
|
||
for f in fichiers:
|
||
try:
|
||
titre, calques = elements_mymaps(lire_kml(os.path.join(dossier, f)))
|
||
except Exception as e:
|
||
print(f" ⚠️ {DOSSIER_MYMAPS}/{f} illisible : {e}")
|
||
continue
|
||
# Calques du plus récent au plus ancien (leurs noms commencent par l'année).
|
||
for nom_calque, elements in sorted(calques.items(), key=lambda kv: kv[0], reverse=True):
|
||
couleur_calque = elements[0]['couleur'] if elements else COULEUR_MYMAPS
|
||
groupe = folium.FeatureGroup(name=str(F.legende(nom_calque, couleur_calque)))
|
||
for e in elements:
|
||
popup = folium.Popup(str(F.popup_mymaps(e['nom'], e['description'])), max_width=300)
|
||
if e['type'] == 'ligne':
|
||
pts = simplifier(e['coords'])
|
||
folium.PolyLine(pts, color=e['couleur'], weight=4, opacity=0.85,
|
||
tooltip=e['nom'] or None, popup=popup).add_to(groupe)
|
||
tous.extend(pts)
|
||
else:
|
||
folium.CircleMarker(e['coords'], radius=6, color='#fff', weight=2,
|
||
fill=True, fill_color=e['couleur'], fill_opacity=1,
|
||
tooltip=e['nom'] or None, popup=popup).add_to(groupe)
|
||
tous.append(e['coords'])
|
||
groupe.add_to(m)
|
||
resume.append(f"{nom_calque} : {len(elements)}")
|
||
|
||
if tous:
|
||
lats = [p[0] for p in tous]
|
||
lons = [p[1] for p in tous]
|
||
m.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]], padding=(20, 20))
|
||
|
||
folium.LayerControl(collapsed=False).add_to(m)
|
||
plugins.Fullscreen(position='topleft').add_to(m)
|
||
m.get_root().html.add_child(folium.Element(str(F.bouton_accueil())))
|
||
# Molette active seulement quand la carte est ouverte seule : intégrée dans
|
||
# l'accueil, elle capterait le défilement de la page.
|
||
m.get_root().script.add_child(folium.Element(str(F.script_page(m.get_name()))))
|
||
m.save(os.path.join(racine, SORTIE))
|
||
print(f"🗺️ Carte globale : {SORTIE} ({', '.join(resume)})")
|