diff --git a/README.md b/README.md
index db9081e..c3d8b65 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,26 @@ Dans le récit `voyage.md`, deux raccourcis évitent les liens écrits en dur (q
[La descente](media:videos/GH011272.mp4)
```
+## L'interface
+
+```
+cd /Volumes/CLEROADTRIPS/Road-Trips
+rtg interface
+```
+
+Le navigateur s'ouvre sur l'interface de RTG (adresse `http://127.0.0.1:8765/`, visible de cet ordinateur seulement). Laisser la fenêtre de Terminal ouverte pendant l'utilisation ; Ctrl+C l'arrête.
+
+- **Mes voyages** : tous les voyages par rubrique, avec leurs dates et l'avancement des vidéos, des photos et des pages. Le bouton « Nouveau voyage » crée le dossier et son `voyage.toml`.
+- **Un voyage**, en quatre onglets :
+ - *Journées* : la carte des traces, jour par jour, et les kilomètres. Une trace sans heures et sans date dans son nom (un itinéraire préparé, par exemple) se rattache ici à un jour. RTG propose le jour où des photos ou des vidéos ont été prises le long de la trace. On peut aussi répartir les traces à la suite, un jour par trace.
+ - *Sources* : où sont les traces, les vidéos et les photos d'origine, choisies dans une fenêtre de dossiers ou collées. Le nombre de fichiers trouvés s'affiche, et un disque débranché est signalé.
+ - *Infos et récit* : titre, moto et récit (`voyage.md`).
+ - *Générer* : tailles des rushs et de ce qui reste à encoder, espace libre, puis lancement de la génération ou de l'encodage seul. L'avancement de chaque vidéo s'affiche ; « Arrêter » interrompt proprement, sans perdre les vidéos déjà finies.
+- **Réglages** : les réglages de `site.toml` (qualité vidéo, taille des photos, fuseau, cartes My Maps) et la vérification des outils installés.
+- **Voir le site** : le site généré, servi tel quel, vidéos comprises.
+
+Tout ce que fait l'interface s'écrit dans `site.toml` et les `voyage.toml`. On peut toujours utiliser les commandes ci-dessous ou modifier ces fichiers à la main. Une réécriture par l'interface conserve les valeurs mais pas les commentaires.
+
## Utilisation : la commande `rtg`
Depuis le dossier du site (`Road-Trips`) :
@@ -125,6 +145,7 @@ rtg generer Balades/Vexin-10-2026 # un voyage, puis l'accueil
rtg generer --sans-compression # tous les voyages, sans encoder de vidéo
rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources
rtg verifier # tout est-il installé ?
+rtg interface # l'interface dans le navigateur
```
Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`.
@@ -139,7 +160,7 @@ videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/Vexin"]
photos = ["~/Pictures/Export/Vexin"]
```
-RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages ou, à défaut, la date du nom de fichier) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`.
+RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages, à défaut la date du nom de fichier, ou le jour choisi dans l'interface et noté dans `[dates_traces]`) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`.
Les scripts de `Scripts/` restent disponibles pendant la transition ; ils produisent le même site. Ils demandent en plus d'installer à la main les modules Python : `pip3 install pandas folium gpxpy markdown2 numpy pillow`.
diff --git a/pyproject.toml b/pyproject.toml
index 6974ee1..b71849c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,7 +30,8 @@ dev = ["pytest>=7"]
rtg = "rtg.cli:main"
[tool.setuptools]
-packages = ["rtg"]
+packages = ["rtg", "rtg.interface"]
[tool.setuptools.package-data]
rtg = ["templates/*"]
+"rtg.interface" = ["modeles/*", "static/*", "static/images/*"]
diff --git a/rtg/accueil.py b/rtg/accueil.py
index 3d7a4e6..39db77d 100644
--- a/rtg/accueil.py
+++ b/rtg/accueil.py
@@ -93,7 +93,7 @@ def fiche(racine, chemin):
quand = date(int(m.group(0)), 1, 1) if m else date.min
return {
'dossier': chemin,
- 'titre': nom.replace('-', ' ').replace('_', ' '),
+ 'titre': reglages_voyage(chemin).get('titre') or nom.replace('-', ' ').replace('_', ' '),
'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')),
'periode': periode(dates),
'moto': reglages_voyage(chemin).get('moto', ''),
diff --git a/rtg/cli.py b/rtg/cli.py
index c41f3ca..d8246bf 100644
--- a/rtg/cli.py
+++ b/rtg/cli.py
@@ -7,6 +7,7 @@
rtg accueil seulement la page d'accueil et la carte globale
rtg migrer écrire site.toml et les voyage.toml depuis l'existant
rtg verifier tout ce dont RTG a besoin est-il installé ?
+ rtg interface ouvrir l'interface dans le navigateur
La racine du site est le premier dossier, en remontant depuis le dossier
courant, qui contient site.toml (sinon le dossier courant) ; --racine la force.
@@ -45,6 +46,10 @@ def main(argv=None):
sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle")
sp.add_parser("verifier", help="contrôler que Python, exiftool et ffmpeg sont bien installés")
+ i = sp.add_parser("interface", help="ouvrir l'interface dans le navigateur")
+ i.add_argument("--port", type=int, default=8765)
+ i.add_argument("--sans-navigateur", action="store_true", help="ne pas ouvrir le navigateur")
+
args = p.parse_args(argv)
if args.commande == "verifier":
@@ -73,6 +78,9 @@ def main(argv=None):
accueil.generer(racine, reglages_site(racine), args.mymaps_maj)
elif args.commande == "migrer":
site.migrer(racine)
+ elif args.commande == "interface":
+ from rtg.interface.serveur import lancer
+ lancer(racine, args.port, ouvrir=not args.sans_navigateur)
except KeyboardInterrupt:
print("\nInterrompu.")
sys.exit(130)
diff --git a/rtg/config.py b/rtg/config.py
index 7c53dc9..29ceee6 100644
--- a/rtg/config.py
+++ b/rtg/config.py
@@ -37,6 +37,7 @@ DEFAUTS = {
"moto": "",
"sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]},
"exclusions": {"fichiers": []},
+ "dates_traces": {}, # trace sans heures → jour imposé (AAAA-MM-JJ)
}
# Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages
@@ -130,18 +131,56 @@ def _toml_valeur(v):
return f'"{s}"'
+def _toml_cle(k):
+ return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else _toml_valeur(k)
+
+
def ecrire_toml(chemin, donnees, entete=""):
"""Écrit un TOML simple (valeurs, listes, une profondeur de tables)."""
lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else [])
for k, v in donnees.items():
if not isinstance(v, dict):
- lignes.append(f"{k} = {_toml_valeur(v)}")
+ lignes.append(f"{_toml_cle(k)} = {_toml_valeur(v)}")
for k, v in donnees.items():
if isinstance(v, dict):
- lignes += ["", f"[{k}]"] + [f"{kk} = {_toml_valeur(vv)}" for kk, vv in v.items()]
+ lignes += ["", f"[{_toml_cle(k)}]"] + [f"{_toml_cle(kk)} = {_toml_valeur(vv)}"
+ for kk, vv in v.items()]
with open(chemin, "w", encoding="utf-8") as f:
f.write("\n".join(lignes) + "\n")
+def modifier_toml(chemin, changements, entete=""):
+ """Applique `changements` à un fichier TOML existant (ou nouveau) et le
+ réécrit. Une valeur None retire la clé ; une table est fusionnée sur un
+ niveau. Le commentaire d'en-tête est conservé, les autres sont perdus."""
+ donnees = lire_toml(chemin)
+ if os.path.exists(chemin):
+ with open(chemin, encoding="utf-8") as f:
+ tete = []
+ for l in f:
+ if not l.startswith("#"):
+ break
+ tete.append(l[1:].strip())
+ entete = "\n".join(tete) or entete
+ for k, v in changements.items():
+ if v is None:
+ donnees.pop(k, None)
+ elif isinstance(v, dict):
+ table = dict(donnees.get(k, {}))
+ for kk, vv in v.items():
+ if vv is None:
+ table.pop(kk, None)
+ else:
+ table[kk] = vv
+ if table:
+ donnees[k] = table
+ else:
+ donnees.pop(k, None)
+ else:
+ donnees[k] = v
+ ecrire_toml(chemin, donnees, entete)
+ return donnees
+
+
def nom_lisible(nom):
return re.sub(r"[-_]+", " ", nom).strip()
diff --git a/rtg/interface/__init__.py b/rtg/interface/__init__.py
new file mode 100644
index 0000000..11f77c5
--- /dev/null
+++ b/rtg/interface/__init__.py
@@ -0,0 +1 @@
+"""Interface locale de RTG (`rtg interface`)."""
diff --git a/rtg/interface/modeles/_analyse.html b/rtg/interface/modeles/_analyse.html
new file mode 100644
index 0000000..332e741
--- /dev/null
+++ b/rtg/interface/modeles/_analyse.html
@@ -0,0 +1,94 @@
+
+{% if not a.details_traces %}
+
Aucune trace GPS. Indiquez où elles se trouvent dans l'onglet Sources .
+{% else %}
+
+
+ {% if non_datees %}
+
+
{{ non_datees|length }} trace{{ 's' if non_datees|length > 1 }} à rattacher à un jour
+
Ces traces n'ont pas d'heures (itinéraire préparé, par exemple) et leur nom ne contient pas de date au format AAAA-MM-JJ.
+ Choisissez le jour de chacune ; les traces en pointillé sur la carte sont celles-ci.
+
+
+ {% for nom in non_datees %}
+ {% set d = a.details_traces|selectattr("nom", "equalto", nom)|first %}
+
+ {{ nom }} {{ d.km|round|int }} km
+
+
+
+
+ {% set s = suggestions.get(nom) %}
+ {% if s and s[0] %}
+ Le {{ s[0]|date_longue }} ?
+ {% else %}pas de suggestion {% endif %}
+
+
+ {% endfor %}
+
+
+ {% if non_datees|length > 1 %}
+
+ {% endif %}
+
+ {% endif %}
+
+
{{ jours|length }} journée{{ 's' if jours|length > 1 }} · {{ total_km|round|int }} km
+
+ Jour km Traces
+
+ {% for j in jours %}
+ {{ j.date|date_longue }}
+ {{ j.km|round|int }}
+ {{ j.traces|join(", ") }}
+ {% endfor %}
+
+
+
+
+ Les {{ a.details_traces|length }} fichier{{ 's' if a.details_traces|length > 1 }} de traces et leur jour
+ Un jour imposé ici remplace celui que RTG déduit des heures ou du nom du fichier.
+
+ Fichier Jour(s) retenu(s) Jour imposé
+
+ {% for d in a.details_traces %}
+
+ {{ d.nom }}{% if d.erreur %} illisible {% endif %}
+ {% for j in d.jours %}{{ j|date_courte }}{{ ", " if not loop.last }}{% else %}— {% endfor %}
+
+
+ {% if d.impose %}
+ Annuler
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+{% set autres = a.a_verifier|reject("commence_par", "Trace à rattacher")|list %}
+{% if autres %}
+
+
À vérifier
+
{% for m in autres %}{{ m }} {% endfor %}
+
+{% endif %}
+
Photos trouvées : {{ a.photos|length }} · Vidéos trouvées : {{ a.videos|length }}
+
diff --git a/rtg/interface/modeles/_bandeau.html b/rtg/interface/modeles/_bandeau.html
new file mode 100644
index 0000000..7ba72f6
--- /dev/null
+++ b/rtg/interface/modeles/_bandeau.html
@@ -0,0 +1,5 @@
+{% if tache and tache.en_cours %}
+⏳ {{ tache.libelle }} · {{ tache.etape }}{% if tache.etape_voyage %} ({{ tache.etape_voyage }}){% endif %}{% if tache.video and tache.video.rang %} · vidéo {{ tache.video.rang[0] }}/{{ tache.video.rang[1] }} ({{ tache.video.pct|int }} %){% endif %}
+{% elif tache %}
+{{ "✓" if tache.ok else "⚠️" }} {{ tache.libelle }} : {{ tache.etape|lower }}
+{% endif %}
diff --git a/rtg/interface/modeles/_dossiers.html b/rtg/interface/modeles/_dossiers.html
new file mode 100644
index 0000000..85b00ca
--- /dev/null
+++ b/rtg/interface/modeles/_dossiers.html
@@ -0,0 +1,27 @@
+
+
+
Choisir un dossier de {{ libelle }}
+
+ {% for nom, c in raccourcis %}
+ {{ nom }}
+ {% endfor %}
+
+
{{ chemin }}
+
+ {% if parent %}⬆︎ Dossier parent {% endif %}
+ {% for d in sous %}
+ 📁 {{ d }}
+ {% else %}
+ Pas de sous-dossier
+ {% endfor %}
+
+
+ {{ nb }} fichier{{ 's' if nb > 1 }} de {{ libelle }} ici
+
+ Annuler
+ Choisir ce dossier
+
+
+
+
diff --git a/rtg/interface/modeles/_infos_utiles.html b/rtg/interface/modeles/_infos_utiles.html
new file mode 100644
index 0000000..ab34607
--- /dev/null
+++ b/rtg/interface/modeles/_infos_utiles.html
@@ -0,0 +1,12 @@
+
+
Rushs (sources) {{ i.nb_rushs }} vidéo{{ 's' if i.nb_rushs > 1 }} · {{ i.rushs|taille }}
+
Reste à encoder
+ {% if i.a_encoder %}{{ i.a_encoder }} · {{ i.a_encoder_octets|taille }}{% else %}rien {% endif %}
+
Vidéos web {{ i.videos_web|taille }}{% if i.apercus %} + aperçus {{ i.apercus|taille }} {% endif %}
+
Photos {{ i.photos|taille }} → web {{ i.photos_web|taille }}
+
Espace libre (site) {{ i.libre|taille }}
+
Encodage
+ {{ noms_qualites.get(cfg.video.qualite, cfg.video.qualite) }} · {{ "matériel" if i.encodage.encodeur == "vt" else "processeur" }}
+
(HEVC CRF {{ i.encodage.crf_hevc }} / H.264 CRF {{ i.encodage.crf_h264 }})
+
changer
+
diff --git a/rtg/interface/modeles/_sources.html b/rtg/interface/modeles/_sources.html
new file mode 100644
index 0000000..22e7ff6
--- /dev/null
+++ b/rtg/interface/modeles/_sources.html
@@ -0,0 +1,31 @@
+
+{% for cle, s in sources.items() %}
+
+
{{ s.libelle }}
+
+ {% for l in s.lignes %}
+
+ {{ l.source }}
+ {% if l.existe %}{{ l.nb }} fichier{{ 's' if l.nb > 1 }}
+ {% elif l.lien_casse %}disque débranché ?
+ {% else %}introuvable {% endif %}
+ Retirer
+
+ {% else %}
+ Aucune source
+ {% endfor %}
+
+
+ Choisir un dossier…
+
+
+
+{% endfor %}
+
diff --git a/rtg/interface/modeles/_systeme.html b/rtg/interface/modeles/_systeme.html
new file mode 100644
index 0000000..ef0003c
--- /dev/null
+++ b/rtg/interface/modeles/_systeme.html
@@ -0,0 +1,2 @@
+{{ lignes|join("\n") }}
+Disque du site : {{ libre|taille }} libres sur {{ total|taille }} · {{ racine }}
diff --git a/rtg/interface/modeles/_tache.html b/rtg/interface/modeles/_tache.html
new file mode 100644
index 0000000..e05b8a4
--- /dev/null
+++ b/rtg/interface/modeles/_tache.html
@@ -0,0 +1,24 @@
+{% if message %}{{ message }}
{% endif %}
+{% if tache %}
+
+
+ {{ tache.libelle }}
+ {{ "⏳ " if tache.en_cours }}{{ tache.etape }}{% if tache.etape_voyage %} — {{ tache.etape_voyage }}{% endif %}
+ {{ tache.duree }}
+ {% if tache.en_cours %}
+ Arrêter
+ {% endif %}
+
+ {% if tache.video and tache.video.rang %}
+ {% set i, n = tache.video.rang %}
+
+
🎬 Vidéo {{ i }}/{{ n }} · {{ tache.video.nom }} · {{ tache.video.pct|int }} %
+
+
+
+ {% endif %}
+ {% set lignes = tache.lignes|list %}
+
{{ lignes[-(1000 if complet else 30):]|join("\n") }}
+
+{% endif %}
diff --git a/rtg/interface/modeles/base.html b/rtg/interface/modeles/base.html
new file mode 100644
index 0000000..d0bb26e
--- /dev/null
+++ b/rtg/interface/modeles/base.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+{% block titre %}{% endblock %} · RTG
+
+{% block tete %}{% endblock %}
+
+
+
+
+
+{% block contenu %}{% endblock %}
+
+
+
+RoadTripsGenerator {{ version }} · {{ racine }}
+
+{% block fin %}{% endblock %}
+
+
diff --git a/rtg/interface/modeles/nouveau.html b/rtg/interface/modeles/nouveau.html
new file mode 100644
index 0000000..ed81941
--- /dev/null
+++ b/rtg/interface/modeles/nouveau.html
@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+{% block titre %}Nouveau voyage{% endblock %}
+{% block contenu %}
+Nouveau voyage
+
+{% endblock %}
diff --git a/rtg/interface/modeles/reglages.html b/rtg/interface/modeles/reglages.html
new file mode 100644
index 0000000..8aafbdd
--- /dev/null
+++ b/rtg/interface/modeles/reglages.html
@@ -0,0 +1,68 @@
+{% extends "base.html" %}
+{% block titre %}Réglages{% endblock %}
+{% block contenu %}
+Réglages du site
+Enregistrés dans site.toml. Ils valent pour tous les voyages.
+
+
+Système
+
+{% endblock %}
diff --git a/rtg/interface/modeles/tache.html b/rtg/interface/modeles/tache.html
new file mode 100644
index 0000000..964f44b
--- /dev/null
+++ b/rtg/interface/modeles/tache.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% block titre %}Traitement{% endblock %}
+{% block contenu %}
+Traitement
+{% if tache %}
+ {% if tache.voyage %}← {{ tache.voyage }}
{% endif %}
+ {% with complet = True %}{% include "_tache.html" %}{% endwith %}
+{% else %}
+ Aucun traitement lancé depuis l'ouverture de l'interface.
+{% endif %}
+{% endblock %}
diff --git a/rtg/interface/modeles/voyage.html b/rtg/interface/modeles/voyage.html
new file mode 100644
index 0000000..214765b
--- /dev/null
+++ b/rtg/interface/modeles/voyage.html
@@ -0,0 +1,68 @@
+{% extends "base.html" %}
+{% block titre %}{{ v.titre }}{% endblock %}
+{% block tete %} {% endblock %}
+{% block contenu %}
+
+
+
Mes voyages › {{ v.rubrique }}
+
{{ v.titre }}
+
{{ [periode(v.debut, v.fin), v.moto]|select|join(" · ") }} {{ v.chemin }}
+
+
+
+
+
+ Journées
+ Sources
+ Infos et récit
+ Générer
+
+
+
+
+
+ Indiquez où se trouvent les fichiers d'origine : un ou plusieurs dossiers (ou fichiers) par type, sur n'importe quel disque.
+ RTG ne les modifie jamais : les traces sont recopiées par journée dans routes/, les photos dans photos/ (HEIC converties en JPEG),
+ et les vidéos sont lues directement pour l'encodage.
+ {% include "_sources.html" %}
+
+
+
+
+
+{% endblock %}
diff --git a/rtg/interface/modeles/voyages.html b/rtg/interface/modeles/voyages.html
new file mode 100644
index 0000000..31020a9
--- /dev/null
+++ b/rtg/interface/modeles/voyages.html
@@ -0,0 +1,63 @@
+{% extends "base.html" %}
+{% block titre %}Mes voyages{% endblock %}
+{% block contenu %}
+{% if not site_existe %}
+
+
Ce dossier n'est pas encore un site RTG. Il n'y a pas de site.toml dans {{ racine }}.
+
Créer le site dans ce dossier
+
Pour un autre dossier, relancez avec rtg interface --racine CHEMIN.
+
+{% endif %}
+
+
+
Mes voyages {{ nb }}
+
+
+
+
+{% for g, liste in groupes %}
+
+ {{ g }} {{ liste|length }}
+
+ Voyage Dates Jours Vidéos web Photos web Pages
+
+ {% for v in liste %}
+
+ {{ v.titre }}
+ {% if v.moto %}{{ v.moto }}
{% endif %}
+ {{ periode(v.debut, v.fin) }}
+ {{ v.jours or "—" }}
+
+ {% if v.videos_sources %}
+ {{ v.videos_faites }}/{{ v.videos_sources }}
+ {% elif v.videos_web %}
+ {{ v.videos_web }} · sources absentes
+ {% else %}— {% endif %}
+
+
+ {% if v.photos %}
+ {{ v.photos_web }}/{{ v.photos }}
+ {% else %}— {% endif %}
+
+
+ {% if not v.genere %}jamais générées
+ {% elif v.pages_a_jour %}à jour
+ {% else %}à refaire {% endif %}
+ {% if v.a_verifier %}⚠️ {{ v.a_verifier|length }} {% endif %}
+
+ {% if v.genere %}Voir ↗ {% endif %}
+
+ {% endfor %}
+
+
+
+{% else %}
+Aucun voyage pour l'instant. Commencez par « Nouveau voyage ».
+{% endfor %}
+{% endblock %}
diff --git a/rtg/interface/serveur.py b/rtg/interface/serveur.py
new file mode 100644
index 0000000..f0e152e
--- /dev/null
+++ b/rtg/interface/serveur.py
@@ -0,0 +1,670 @@
+"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur.
+
+Rien n'est installé en plus : le serveur vient de Python, les pages sont des
+modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont
+fournis dans static/. Le serveur n'écoute que sur 127.0.0.1.
+
+Sécurité : une page web quelconque ouverte dans le navigateur pourrait
+envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose
+doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter
+sans l'accord du serveur), et l'en-tête Host doit être le nôtre.
+"""
+import io
+import json
+import mimetypes
+import os
+import platform
+import re
+import shutil
+import string
+import threading
+import webbrowser
+from contextlib import redirect_stdout
+from datetime import date, timedelta
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import parse_qs, quote, unquote, urlsplit
+
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+from rtg import __version__, accueil, site, sources, video
+from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
+ reglages_site, reglages_voyage)
+from rtg.interface import taches
+from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
+
+ICI = os.path.dirname(os.path.abspath(__file__))
+STATIC = os.path.join(ICI, "static")
+MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août",
+ "septembre", "octobre", "novembre", "décembre"]
+JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
+COULEURS = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#46a0b4", "#f032e6",
+ "#7a9a01", "#c47f00", "#008080", "#9a6324", "#800000", "#2f4b7c", "#808000"]
+CLES_SOURCES = {"traces": ("Traces GPS", (".gpx",), "traces GPS"),
+ "videos": ("Vidéos", EXT_VIDEOS, "vidéos"),
+ "photos": ("Photos", EXT_PHOTOS, "photos")}
+
+
+def _date_longue(d):
+ return f"{JOURS[d.weekday()]} {d.day} {MOIS[d.month - 1]} {d.year}" if d else ""
+
+
+def _periode(debut, fin):
+ if not debut:
+ return ""
+ if debut == fin:
+ return f"{debut.day} {MOIS[debut.month - 1]} {debut.year}"
+ if debut.year == fin.year:
+ return f"{debut.day} {MOIS[debut.month - 1]} – {fin.day} {MOIS[fin.month - 1]} {fin.year}"
+ return f"{debut.day} {MOIS[debut.month - 1]} {debut.year} – {fin.day} {MOIS[fin.month - 1]} {fin.year}"
+
+
+ENV = Environment(loader=FileSystemLoader(os.path.join(ICI, "modeles")),
+ autoescape=select_autoescape(["html"]), trim_blocks=True, lstrip_blocks=True)
+ENV.filters["date_longue"] = _date_longue
+ENV.filters["date_courte"] = lambda d: f"{d.day} {MOIS[d.month - 1][:4].rstrip('.')}" if d else ""
+ENV.filters["taille"] = taille_lisible
+ENV.filters["url"] = lambda s: quote(str(s), safe="")
+ENV.tests["commence_par"] = lambda s, debut: str(s).startswith(debut)
+ENV.globals.update(version=__version__, periode=_periode, qualites=QUALITES,
+ noms_qualites={"econome": "économe", "equilibree": "équilibrée", "haute": "haute"})
+
+
+class Erreur(Exception):
+ def __init__(self, code, message):
+ super().__init__(message)
+ self.code, self.message = code, message
+
+
+class Reponse:
+ def __init__(self, corps=b"", type_="text/html; charset=utf-8", code=200, entetes=None):
+ self.corps = corps.encode("utf-8") if isinstance(corps, str) else corps
+ self.type, self.code, self.entetes = type_, code, entetes or {}
+
+
+def page(modele, **ctx):
+ return Reponse(ENV.get_template(modele).render(**ctx))
+
+
+def json_rep(donnees):
+ return Reponse(json.dumps(donnees, ensure_ascii=False, default=str), "application/json")
+
+
+# --- Application ---------------------------------------------------------------
+class Application:
+ def __init__(self, racine):
+ self.racine = os.path.abspath(racine)
+ self._cache = {} # voyage → (empreinte des sources, analyse)
+ self.routes = []
+ for methode, motif, fn in [
+ ("GET", r"/", self.accueil),
+ ("POST", r"/site/creer", self.creer_site),
+ ("GET", r"/nouveau", self.nouveau),
+ ("POST", r"/nouveau", self.nouveau_creer),
+ ("GET", r"/voyage", self.voyage),
+ ("POST", r"/voyage/infos", self.voyage_infos),
+ ("POST", r"/voyage/recit", self.voyage_recit),
+ ("POST", r"/voyage/sources", self.voyage_sources),
+ ("GET", r"/voyage/analyse", self.voyage_analyse),
+ ("POST", r"/voyage/date", self.voyage_date),
+ ("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
+ ("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
+ ("GET", r"/dossiers", self.dossiers),
+ ("GET", r"/reglages", self.reglages),
+ ("POST", r"/reglages", self.reglages_enregistrer),
+ ("GET", r"/systeme", self.systeme),
+ ("POST", r"/tache/lancer", self.tache_lancer),
+ ("POST", r"/tache/arreter", self.tache_arreter),
+ ("GET", r"/tache", self.tache),
+ ("GET", r"/tache/fragment", self.tache_fragment),
+ ("GET", r"/tache/bandeau", self.tache_bandeau),
+ ]:
+ self.routes.append((methode, re.compile(motif + "$"), fn))
+
+ # --- outils
+ def contexte(self, **ctx):
+ ctx.setdefault("racine", self.racine)
+ ctx.setdefault("site_existe", os.path.exists(os.path.join(self.racine, SITE_TOML)))
+ ctx.setdefault("tache", taches.COURANTE)
+ return ctx
+
+ def chemin_voyage(self, rel):
+ if not rel:
+ raise Erreur(400, "Voyage non précisé")
+ chemin = os.path.realpath(os.path.join(self.racine, rel))
+ if not chemin.startswith(os.path.realpath(self.racine) + os.sep) or not site.est_voyage(chemin):
+ raise Erreur(404, f"Voyage introuvable : {rel}")
+ return chemin
+
+ def cfg_voyage(self, chemin):
+ return reglages_voyage(chemin, reglages_site(self.racine))
+
+ def fiche_voyage(self, chemin):
+ return site.etat_voyage(chemin, self.racine, reglages_site(self.racine))
+
+ def toml_voyage(self, chemin, changements):
+ modifier_toml(os.path.join(chemin, VOYAGE_TOML), changements,
+ entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.")
+
+ # --- Mes voyages
+ def accueil(self, r):
+ voyages = site.tableau_etat(self.racine)
+ groupes = {}
+ for v in sorted(voyages, key=lambda v: v["debut"] or date.min, reverse=True):
+ groupes.setdefault(v["rubrique"], []).append(v)
+ ordre = sorted(groupes, key=lambda g: (g != "Voyages", g))
+ return page("voyages.html", **self.contexte(groupes=[(g, groupes[g]) for g in ordre],
+ nb=len(voyages)))
+
+ def creer_site(self, r):
+ chemin = os.path.join(self.racine, SITE_TOML)
+ if not os.path.exists(chemin):
+ modifier_toml(chemin, {"titre": "Nos Road-Trips"},
+ entete="Réglages du site RoadTripsGenerator (rtg).")
+ return Reponse("", entetes={"HX-Redirect": "/"})
+
+ def groupes_existants(self):
+ res = []
+ for d in sorted(os.listdir(self.racine)):
+ c = os.path.join(self.racine, d)
+ if os.path.isdir(c) and not d.startswith((".", "_")) and d not in site.IGNORES \
+ and not site.est_voyage(c):
+ res.append(d)
+ return res
+
+ def nouveau(self, r):
+ return page("nouveau.html", **self.contexte(groupes=self.groupes_existants()))
+
+ def nouveau_creer(self, r):
+ dossier = r.f("dossier").strip()
+ groupe = r.f("groupe_nouveau").strip() or r.f("groupe").strip()
+ if not dossier:
+ return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(),
+ erreur="Indiquez un nom de dossier.", form=r.form))
+ try:
+ chemin = site.creer_voyage(self.racine, dossier, r.f("titre").strip(), groupe, r.f("moto").strip())
+ except (ValueError, OSError) as e:
+ return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(),
+ erreur=str(e), form=r.form))
+ rel = os.path.relpath(chemin, self.racine).replace(os.sep, "/")
+ return Reponse("", entetes={"HX-Redirect": f"/voyage?v={quote(rel)}#sources"})
+
+ # --- Un voyage
+ def voyage(self, r):
+ chemin = self.chemin_voyage(r.q("v"))
+ cfg = self.cfg_voyage(chemin)
+ recit_f = os.path.join(chemin, "voyage.md")
+ recit = open(recit_f, encoding="utf-8").read() if os.path.exists(recit_f) else ""
+ return page("voyage.html", **self.contexte(
+ v=self.fiche_voyage(chemin), cfg=cfg, recit=recit,
+ sources=self.etat_sources(chemin, cfg)))
+
+ def voyage_infos(self, r):
+ chemin = self.chemin_voyage(r.f("v"))
+ self.toml_voyage(chemin, {"titre": r.f("titre").strip() or None, "moto": r.f("moto").strip() or None})
+ return Reponse('✓ Enregistré. Les pages seront à jour à la prochaine génération. ')
+
+ def voyage_recit(self, r):
+ chemin = self.chemin_voyage(r.f("v"))
+ texte = r.f("recit").replace("\r\n", "\n")
+ f = os.path.join(chemin, "voyage.md")
+ if texte.strip() or os.path.exists(f):
+ with open(f, "w", encoding="utf-8") as fh:
+ fh.write(texte)
+ return Reponse('✓ Récit enregistré. ')
+
+ def etat_sources(self, chemin, cfg):
+ res = {}
+ for cle, (libelle, ext, _) in CLES_SOURCES.items():
+ lignes = []
+ for s in cfg["sources"].get(cle, []):
+ p = os.path.join(chemin, os.path.expanduser(s))
+ fichiers = lister_fichiers([s], ext, chemin) if os.path.exists(p) else []
+ lignes.append({"source": s, "absolu": os.path.abspath(p), "existe": os.path.exists(p),
+ "lien_casse": os.path.islink(p) and not os.path.exists(p),
+ "nb": len(fichiers)})
+ res[cle] = {"libelle": libelle, "lignes": lignes}
+ return res
+
+ def voyage_sources(self, r):
+ chemin = self.chemin_voyage(r.f("v"))
+ cle = r.f("cle")
+ if cle not in CLES_SOURCES:
+ raise Erreur(400, "Type de source inconnu")
+ cfg = self.cfg_voyage(chemin)
+ liste = list(cfg["sources"].get(cle, []))
+ source = r.f("chemin").strip()
+ if r.f("action") == "ajouter" and source:
+ source = os.path.abspath(os.path.expanduser(source))
+ if not os.path.exists(source):
+ raise Erreur(400, f"Introuvable : {source}")
+ # Dans le dossier du voyage : chemin relatif (le site reste déplaçable).
+ if os.path.realpath(source).startswith(os.path.realpath(chemin) + os.sep):
+ source = os.path.relpath(os.path.realpath(source), os.path.realpath(chemin))
+ if source not in liste:
+ liste.append(source)
+ elif r.f("action") == "retirer":
+ liste = [s for s in liste if s != source]
+ self.toml_voyage(chemin, {"sources": {cle: liste}})
+ cfg = self.cfg_voyage(chemin)
+ rel = r.f("v")
+ return Reponse(ENV.get_template("_sources.html").render(
+ v={"chemin": rel}, sources=self.etat_sources(chemin, cfg)),
+ entetes={"HX-Trigger": "sources-modifiees"})
+
+ def _empreinte(self, chemin, cfg):
+ """Ce dont dépend l'analyse : si rien n'a changé, on la réutilise."""
+ def etat(f):
+ try:
+ st = os.stat(f)
+ return (f, st.st_mtime, st.st_size)
+ except OSError:
+ return (f, None, None)
+ fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin)
+ autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")]
+ autres.append(os.path.join(self.racine, SITE_TOML))
+ photos = cfg["sources"].get("photos", [])
+ return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),)
+
+ def analyse(self, chemin):
+ cfg = self.cfg_voyage(chemin)
+ cle = self._empreinte(chemin, cfg)
+ if self._cache.get(chemin, (None,))[0] == cle:
+ return self._cache[chemin][1]
+ res = self._analyse(chemin, cfg)
+ self._cache[chemin] = (cle, res)
+ return res
+
+ def _analyse(self, chemin, cfg):
+ a = sources.analyser(chemin, cfg)
+ jours = []
+ for i, j in enumerate(sorted(a.journees)):
+ noms = sorted({d["nom"] for d in a.details_traces if j in d["jours"]})
+ jours.append({"date": j, "km": a.km(j), "traces": noms, "couleur": COULEURS[i % len(COULEURS)]})
+ suggestions = {}
+ if a.non_datees:
+ medias = sources.positions_medias(chemin, a)
+ for f, pistes in a.non_datees.items():
+ suggestions[os.path.basename(f)] = sources.suggerer_jour(pistes, medias)
+ return cfg, a, jours, suggestions
+
+ def voyage_analyse(self, r):
+ chemin = self.chemin_voyage(r.q("v"))
+ cfg, a, jours, suggestions = self.analyse(chemin)
+ total = sum(j["km"] for j in jours)
+ return page("_analyse.html", v={"chemin": r.q("v")}, a=a, jours=jours, total_km=total,
+ suggestions=suggestions, cfg=cfg, couches=_couches(a, jours),
+ non_datees=sorted(os.path.basename(f) for f in a.non_datees))
+
+ def voyage_date(self, r):
+ chemin = self.chemin_voyage(r.f("v"))
+ nom, jour = r.f("fichier"), r.f("jour").strip()
+ if jour and not sources.jour_texte(jour):
+ raise Erreur(400, f"Date invalide : {jour}")
+ self.toml_voyage(chemin, {"dates_traces": {nom: jour or None}})
+ return self._rafraichir_analyse(r.f("v"))
+
+ def voyage_dates_suite(self, r):
+ chemin = self.chemin_voyage(r.f("v"))
+ debut = sources.jour_texte(r.f("debut"))
+ if not debut:
+ raise Erreur(400, "Indiquez le premier jour")
+ _, a, _, _ = self.analyse(chemin)
+ # Toutes les traces qui ne se datent pas seules, même déjà rattachées.
+ noms = sorted(d["nom"] for d in a.details_traces if not d["erreur"] and not d["datable"])
+ self.toml_voyage(chemin, {"dates_traces": {n: (debut + timedelta(days=i)).isoformat()
+ for i, n in enumerate(noms)}})
+ return self._rafraichir_analyse(r.f("v"))
+
+ def _rafraichir_analyse(self, rel):
+ return self.voyage_analyse(Requete({"v": rel}, {}))
+
+ def voyage_infos_utiles(self, r):
+ chemin = self.chemin_voyage(r.q("v"))
+ cfg = self.cfg_voyage(chemin)
+ a = sources.analyser(chemin, cfg, leger=True)
+
+ def taille_dossier(d):
+ d = os.path.join(chemin, d)
+ if not os.path.isdir(d):
+ return 0
+ return sum(e.stat().st_size for e in os.scandir(d) if e.is_file() and not est_cache(e.name))
+ rushs = sum(os.path.getsize(f) for f in a.videos if os.path.exists(f))
+ infos = {
+ "rushs": rushs, "nb_rushs": len(a.videos),
+ "videos_web": taille_dossier("videos"), "photos": taille_dossier("photos"),
+ "photos_web": taille_dossier("photos_web"), "apercus": taille_dossier("apercus"),
+ "libre": shutil.disk_usage(chemin).free,
+ "encodage": parametres_video(cfg),
+ }
+ reste = [f for f in a.videos if not video.deja_faite(chemin, f, rapide=True)]
+ infos["a_encoder"] = len(reste)
+ infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
+ return page("_infos_utiles.html", i=infos, cfg=cfg)
+
+ # --- Navigateur de dossiers
+ def dossiers(self, r):
+ chemin = r.q("chemin") or os.path.expanduser("~")
+ chemin = os.path.abspath(os.path.expanduser(chemin))
+ if not os.path.isdir(chemin):
+ chemin = os.path.dirname(chemin)
+ cle = r.q("cle")
+ ext = CLES_SOURCES.get(cle, ("", (), ""))[1]
+ sous, nb = [], 0
+ try:
+ for e in sorted(os.scandir(chemin), key=lambda e: e.name.lower()):
+ if est_cache(e.name):
+ continue
+ if e.is_dir():
+ sous.append(e.name)
+ elif ext and e.name.lower().endswith(ext):
+ nb += 1
+ except PermissionError:
+ pass
+ raccourcis = [("Dossier personnel", os.path.expanduser("~")), ("Site", self.racine)]
+ if r.q("v"):
+ raccourcis.append(("Ce voyage", os.path.join(self.racine, r.q("v"))))
+ if platform.system() == "Darwin" and os.path.isdir("/Volumes"):
+ raccourcis += [(d, os.path.join("/Volumes", d)) for d in sorted(os.listdir("/Volumes"))
+ if not d.startswith(".")]
+ elif platform.system() == "Windows":
+ raccourcis += [(f"{l}:", f"{l}:\\") for l in string.ascii_uppercase if os.path.exists(f"{l}:\\")]
+ else:
+ for base in ("/media", "/mnt", f"/media/{os.environ.get('USER', '')}"):
+ if os.path.isdir(base):
+ raccourcis += [(d, os.path.join(base, d)) for d in sorted(os.listdir(base))
+ if os.path.isdir(os.path.join(base, d)) and not d.startswith(".")]
+ parent = os.path.dirname(chemin)
+ return page("_dossiers.html", chemin=chemin, parent=parent if parent != chemin else None,
+ sous=sous, nb=nb, cle=cle, libelle=CLES_SOURCES.get(cle, ("", (), "fichiers"))[2],
+ v=r.q("v"), raccourcis=raccourcis, sep=os.sep)
+
+ # --- Réglages du site
+ def reglages(self, r):
+ return page("reglages.html", **self.contexte(cfg=reglages_site(self.racine)))
+
+ def reglages_enregistrer(self, r):
+ def entier(k, defaut):
+ try:
+ return int(r.f(k))
+ except ValueError:
+ return defaut
+ cfg = reglages_site(self.racine)
+ liens = [l.strip() for l in r.f("mymaps").splitlines() if l.strip()]
+ modifier_toml(os.path.join(self.racine, SITE_TOML), {
+ "titre": r.f("titre").strip() or cfg["titre"],
+ "adresse_publique": r.f("adresse_publique").strip(),
+ "accueil_nb_recents": entier("accueil_nb_recents", 2),
+ "video": {"qualite": r.f("qualite") if r.f("qualite") in QUALITES else "equilibree",
+ "encodeur": "materiel" if r.f("encodeur") == "materiel" else "processeur",
+ "preset": r.f("preset") or "medium", "jobs": entier("jobs", 0)},
+ "photos": {"grand_cote": entier("grand_cote", 1600), "qualite": entier("qualite_photos", 80),
+ "positions_deduites": r.f("positions_deduites") == "on"},
+ "journees": {"fuseau": r.f("fuseau").strip() or "Europe/Paris",
+ "coupure_heure": entier("coupure_heure", 4)},
+ "carte_globale": {"mymaps": liens},
+ }, entete="Réglages du site RoadTripsGenerator (rtg).")
+ return Reponse('✓ Réglages enregistrés. ')
+
+ def systeme(self, r):
+ from rtg.verifier import verifier
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ ok = verifier()
+ libre = shutil.disk_usage(self.racine)
+ return page("_systeme.html", lignes=tampon.getvalue().splitlines(), ok=ok,
+ libre=libre.free, total=libre.total, racine=self.racine)
+
+ # --- Traitements
+ def tache_lancer(self, r):
+ quoi, rel = r.f("quoi"), r.f("v")
+ compression = r.f("sans_compression") != "on"
+ force = r.f("force") == "on"
+ racine = self.racine
+ if quoi in ("generer", "compresser"):
+ chemin = self.chemin_voyage(rel)
+ if quoi == "generer":
+ libelle = f"Génération de {rel}" + ("" if compression else " (sans les vidéos)")
+
+ def fn(suivi):
+ return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi)
+ elif quoi == "compresser":
+ libelle = f"Compression des vidéos de {rel}"
+
+ def fn(suivi):
+ cfg = reglages_voyage(chemin, reglages_site(racine))
+ suivi({"etape": "Vidéos", "voyage": rel})
+ return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=force,
+ progression=lambda n, e, p, rg: suivi(
+ {"video": n, "etat": e, "pct": p, "rang": rg}))
+ elif quoi == "tout":
+ libelle = "Génération de tout le site" + ("" if compression else " (sans les vidéos)")
+
+ def fn(suivi):
+ return site.generer(racine, None, compression=compression, force=force, suivi=suivi)
+ elif quoi == "accueil":
+ libelle = "Page d'accueil et carte globale"
+
+ def fn(suivi):
+ suivi({"etape": "Accueil"})
+ accueil.generer(racine, reglages_site(racine), r.f("mymaps_maj") == "on")
+ else:
+ raise Erreur(400, "Traitement inconnu")
+ t = taches.lancer(libelle, fn, rel if quoi in ("generer", "compresser") else "")
+ message = None if t else "Un traitement est déjà en cours : attendez qu'il se termine ou arrêtez-le."
+ return page("_tache.html", tache=taches.COURANTE, message=message)
+
+ def tache_arreter(self, r):
+ taches.arreter()
+ return page("_tache.html", tache=taches.COURANTE)
+
+ def tache(self, r):
+ return page("tache.html", **self.contexte())
+
+ def tache_fragment(self, r):
+ t = taches.COURANTE
+ rep = page("_tache.html", tache=t, complet=r.q("complet") == "1")
+ if t and not t.en_cours:
+ # Dernière relève : les autres zones de la page se mettent à jour.
+ rep.entetes["HX-Trigger"] = "tache-finie"
+ return rep
+
+ def tache_bandeau(self, r):
+ return page("_bandeau.html", tache=taches.COURANTE)
+
+
+# --- HTTP ----------------------------------------------------------------------
+def _couches(a, jours, maxi=1500):
+ """Traces de chaque journée pour la carte (au plus `maxi` points par jour)."""
+ def coords(pistes):
+ segments = [s.points for t in pistes for s in t.segments if len(s.points) > 1]
+ pas = max(1, sum(len(s) for s in segments) // maxi)
+ res = []
+ for pts in segments:
+ seg = [[round(p.latitude, 5), round(p.longitude, 5)] for p in pts[::pas]]
+ if (len(pts) - 1) % pas:
+ seg.append([round(pts[-1].latitude, 5), round(pts[-1].longitude, 5)])
+ res.append(seg)
+ return res
+ couches = [{"nom": _date_longue(j["date"]), "km": round(j["km"]), "couleur": j["couleur"],
+ "segments": coords(a.journees[j["date"]])} for j in jours]
+ for f, pistes in a.non_datees.items():
+ couches.append({"nom": f"{os.path.basename(f)} (sans jour)", "couleur": "#555",
+ "pointille": True, "km": 0, "segments": coords(pistes)})
+ return couches
+
+
+def _valeur(d, k, defaut):
+ v = d.get(k, defaut)
+ if isinstance(v, list):
+ return v[0] if v else defaut
+ return v
+
+
+class Requete:
+ def __init__(self, query, form):
+ self.query, self.form = query, form
+
+ def q(self, k, defaut=""):
+ return _valeur(self.query, k, defaut)
+
+ def f(self, k, defaut=""):
+ return _valeur(self.form, k, defaut)
+
+
+def creer_gestionnaire(app, port):
+ hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
+
+ class Gestionnaire(BaseHTTPRequestHandler):
+ server_version = f"rtg/{__version__}"
+
+ def log_message(self, fmt, *args):
+ pass # le terminal sert au journal des traitements
+
+ def do_GET(self):
+ self.traiter("GET")
+
+ def do_HEAD(self):
+ self.traiter("GET", tete=True)
+
+ def do_POST(self):
+ self.traiter("POST")
+
+ def traiter(self, methode, tete=False):
+ try:
+ if self.headers.get("Host") not in hotes:
+ raise Erreur(403, "Hôte refusé")
+ url = urlsplit(self.path)
+ chemin = unquote(url.path)
+ if methode == "GET" and chemin.startswith("/static/"):
+ return self.fichier(STATIC, chemin[len("/static/"):], tete)
+ if methode == "GET" and chemin.startswith("/site/"):
+ return self.fichier(app.racine, chemin[len("/site/"):], tete)
+ form = {}
+ if methode == "POST":
+ if self.headers.get("HX-Request") != "true":
+ raise Erreur(403, "Requête refusée")
+ n = int(self.headers.get("Content-Length") or 0)
+ form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
+ req = Requete(parse_qs(url.query, keep_blank_values=True), form)
+ for m, motif, fn in app.routes:
+ if m == methode and motif.match(chemin):
+ rep = fn(req)
+ break
+ else:
+ raise Erreur(404, "Page introuvable")
+ except Erreur as e:
+ rep = Reponse(f'{_echapper(e.message)}
', code=e.code)
+ if self.headers.get("HX-Request") == "true":
+ # htmx n'affiche pas les réponses en erreur : on renvoie 200
+ # vers la zone de messages.
+ rep.code = 200
+ rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ rep = Reponse(f'Erreur : {_echapper(str(e))}
', code=500)
+ if self.headers.get("HX-Request") == "true":
+ rep.code = 200
+ rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
+ self.envoyer(rep, tete)
+
+ def envoyer(self, rep, tete=False):
+ self.send_response(rep.code)
+ self.send_header("Content-Type", rep.type)
+ self.send_header("Content-Length", str(len(rep.corps)))
+ self.send_header("Cache-Control", "no-store")
+ for k, v in rep.entetes.items():
+ self.send_header(k, v)
+ self.end_headers()
+ if not tete:
+ self.wfile.write(rep.corps)
+
+ def fichier(self, base, rel, tete):
+ """Fichier statique, avec les requêtes partielles (Range) dont
+ Safari a besoin pour lire les vidéos."""
+ base = os.path.realpath(base)
+ chemin = os.path.realpath(os.path.join(base, rel))
+ if chemin != base and not chemin.startswith(base + os.sep):
+ raise Erreur(403, "Chemin refusé")
+ if os.path.isdir(chemin):
+ if not self.path.split("?")[0].endswith("/"):
+ self.send_response(301)
+ self.send_header("Location", self.path.split("?")[0] + "/")
+ self.end_headers()
+ return
+ chemin = os.path.join(chemin, "index.html")
+ if not os.path.isfile(chemin):
+ return self.envoyer(Reponse("Fichier introuvable", "text/plain; charset=utf-8", 404), tete)
+ taille = os.path.getsize(chemin)
+ type_ = mimetypes.guess_type(chemin)[0] or "application/octet-stream"
+ if type_.startswith("text/") or type_ in ("application/javascript", "application/json"):
+ type_ += "; charset=utf-8"
+ debut, fin, code = 0, taille - 1, 200
+ m = re.match(r"bytes=(\d*)-(\d*)$", self.headers.get("Range", ""))
+ if m and taille:
+ if m.group(1):
+ debut = int(m.group(1))
+ fin = min(int(m.group(2)), taille - 1) if m.group(2) else taille - 1
+ else:
+ debut = max(0, taille - int(m.group(2) or 0))
+ if debut > fin:
+ self.send_response(416)
+ self.send_header("Content-Range", f"bytes */{taille}")
+ self.end_headers()
+ return
+ code = 206
+ self.send_response(code)
+ self.send_header("Content-Type", type_)
+ self.send_header("Accept-Ranges", "bytes")
+ self.send_header("Content-Length", str(fin - debut + 1 if taille else 0))
+ if code == 206:
+ self.send_header("Content-Range", f"bytes {debut}-{fin}/{taille}")
+ self.end_headers()
+ if tete or not taille:
+ return
+ try:
+ with open(chemin, "rb") as f:
+ f.seek(debut)
+ reste = fin - debut + 1
+ while reste > 0:
+ bloc = f.read(min(1 << 20, reste))
+ if not bloc:
+ break
+ self.wfile.write(bloc)
+ reste -= len(bloc)
+ except (BrokenPipeError, ConnectionResetError):
+ pass # le navigateur a interrompu (vidéo)
+
+ return Gestionnaire
+
+
+def _echapper(s):
+ return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+def lancer(racine, port=8765, ouvrir=True):
+ app = Application(racine)
+ serveur = None
+ for p in range(port, port + 20):
+ try:
+ serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p))
+ port = p
+ break
+ except OSError:
+ continue
+ if serveur is None:
+ raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
+ serveur.daemon_threads = True
+ url = f"http://127.0.0.1:{port}/"
+ print(f"🌐 Interface RTG : {url}")
+ print(f" Site : {app.racine}")
+ print(" (Ctrl+C pour arrêter)")
+ if ouvrir:
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
+ try:
+ serveur.serve_forever()
+ except KeyboardInterrupt:
+ taches.arreter()
+ print("\nInterface arrêtée.")
+ finally:
+ serveur.server_close()
diff --git a/rtg/interface/static/LICENCES.txt b/rtg/interface/static/LICENCES.txt
new file mode 100644
index 0000000..9b7f486
--- /dev/null
+++ b/rtg/interface/static/LICENCES.txt
@@ -0,0 +1,32 @@
+Bibliothèques tierces fournies avec l'interface de RTG
+
+htmx 1.9.12 (htmx.min.js) — https://htmx.org — licence Zero-Clause BSD
+
+Leaflet 1.9.4 (leaflet.js, leaflet.css, images/) — https://leafletjs.com
+
+BSD 2-Clause License
+
+Copyright (c) 2010-2023, Volodymyr Agafonkin
+Copyright (c) 2010-2011, CloudMade
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/rtg/interface/static/htmx.min.js b/rtg/interface/static/htmx.min.js
new file mode 100644
index 0000000..de5f0f1
--- /dev/null
+++ b/rtg/interface/static/htmx.min.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/"+n+" ",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("",1);case"col":return s("",2);case"tr":return s("",2);case"td":case"th":return s("",3);case"script":case"style":return s(""+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()});
\ No newline at end of file
diff --git a/rtg/interface/static/images/layers-2x.png b/rtg/interface/static/images/layers-2x.png
new file mode 100644
index 0000000..200c333
Binary files /dev/null and b/rtg/interface/static/images/layers-2x.png differ
diff --git a/rtg/interface/static/images/layers.png b/rtg/interface/static/images/layers.png
new file mode 100644
index 0000000..1a72e57
Binary files /dev/null and b/rtg/interface/static/images/layers.png differ
diff --git a/rtg/interface/static/images/marker-icon-2x.png b/rtg/interface/static/images/marker-icon-2x.png
new file mode 100644
index 0000000..88f9e50
Binary files /dev/null and b/rtg/interface/static/images/marker-icon-2x.png differ
diff --git a/rtg/interface/static/images/marker-icon.png b/rtg/interface/static/images/marker-icon.png
new file mode 100644
index 0000000..950edf2
Binary files /dev/null and b/rtg/interface/static/images/marker-icon.png differ
diff --git a/rtg/interface/static/images/marker-shadow.png b/rtg/interface/static/images/marker-shadow.png
new file mode 100644
index 0000000..9fd2979
Binary files /dev/null and b/rtg/interface/static/images/marker-shadow.png differ
diff --git a/rtg/interface/static/leaflet.css b/rtg/interface/static/leaflet.css
new file mode 100644
index 0000000..2961b76
--- /dev/null
+++ b/rtg/interface/static/leaflet.css
@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+.leaflet-container {
+ overflow: hidden;
+ }
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+ background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+ image-rendering: -webkit-optimize-contrast;
+ }
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+ width: 1600px;
+ height: 1600px;
+ -webkit-transform-origin: 0 0;
+ }
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ display: block;
+ }
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+ max-width: none !important;
+ max-height: none !important;
+ }
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+ max-width: none !important;
+ max-height: none !important;
+ width: auto;
+ padding: 0;
+ }
+
+.leaflet-container img.leaflet-tile {
+ /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+ mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+ -ms-touch-action: pan-x pan-y;
+ touch-action: pan-x pan-y;
+ }
+.leaflet-container.leaflet-touch-drag {
+ -ms-touch-action: pinch-zoom;
+ /* Fallback for FF which doesn't support pinch-zoom */
+ touch-action: none;
+ touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+ -ms-touch-action: none;
+ touch-action: none;
+}
+.leaflet-container {
+ -webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+ -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+ filter: inherit;
+ visibility: hidden;
+ }
+.leaflet-tile-loaded {
+ visibility: inherit;
+ }
+.leaflet-zoom-box {
+ width: 0;
+ height: 0;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ z-index: 800;
+ }
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+ -moz-user-select: none;
+ }
+
+.leaflet-pane { z-index: 400; }
+
+.leaflet-tile-pane { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane { z-index: 500; }
+.leaflet-marker-pane { z-index: 600; }
+.leaflet-tooltip-pane { z-index: 650; }
+.leaflet-popup-pane { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg { z-index: 200; }
+
+.leaflet-vml-shape {
+ width: 1px;
+ height: 1px;
+ }
+.lvml {
+ behavior: url(#default#VML);
+ display: inline-block;
+ position: absolute;
+ }
+
+
+/* control positioning */
+
+.leaflet-control {
+ position: relative;
+ z-index: 800;
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+.leaflet-top,
+.leaflet-bottom {
+ position: absolute;
+ z-index: 1000;
+ pointer-events: none;
+ }
+.leaflet-top {
+ top: 0;
+ }
+.leaflet-right {
+ right: 0;
+ }
+.leaflet-bottom {
+ bottom: 0;
+ }
+.leaflet-left {
+ left: 0;
+ }
+.leaflet-control {
+ float: left;
+ clear: both;
+ }
+.leaflet-right .leaflet-control {
+ float: right;
+ }
+.leaflet-top .leaflet-control {
+ margin-top: 10px;
+ }
+.leaflet-bottom .leaflet-control {
+ margin-bottom: 10px;
+ }
+.leaflet-left .leaflet-control {
+ margin-left: 10px;
+ }
+.leaflet-right .leaflet-control {
+ margin-right: 10px;
+ }
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ -moz-transition: opacity 0.2s linear;
+ transition: opacity 0.2s linear;
+ }
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+ opacity: 1;
+ }
+.leaflet-zoom-animated {
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ }
+svg.leaflet-zoom-animated {
+ will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+ -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+ -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+ transition: transform 0.25s cubic-bezier(0,0,0.25,1);
+ }
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none;
+ }
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+ visibility: hidden;
+ }
+
+
+/* cursors */
+
+.leaflet-interactive {
+ cursor: pointer;
+ }
+.leaflet-grab {
+ cursor: -webkit-grab;
+ cursor: -moz-grab;
+ cursor: grab;
+ }
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+ cursor: crosshair;
+ }
+.leaflet-popup-pane,
+.leaflet-control {
+ cursor: auto;
+ }
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+ cursor: move;
+ cursor: -webkit-grabbing;
+ cursor: -moz-grabbing;
+ cursor: grabbing;
+ }
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+ pointer-events: none;
+ }
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+
+/* visual tweaks */
+
+.leaflet-container {
+ background: #ddd;
+ outline-offset: 1px;
+ }
+.leaflet-container a {
+ color: #0078A8;
+ }
+.leaflet-zoom-box {
+ border: 2px dotted #38f;
+ background: rgba(255,255,255,0.5);
+ }
+
+
+/* general typography */
+.leaflet-container {
+ font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+ font-size: 12px;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ }
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+ border-radius: 4px;
+ }
+.leaflet-bar a {
+ background-color: #fff;
+ border-bottom: 1px solid #ccc;
+ width: 26px;
+ height: 26px;
+ line-height: 26px;
+ display: block;
+ text-align: center;
+ text-decoration: none;
+ color: black;
+ }
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ display: block;
+ }
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+ background-color: #f4f4f4;
+ }
+.leaflet-bar a:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+.leaflet-bar a:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom: none;
+ }
+.leaflet-bar a.leaflet-disabled {
+ cursor: default;
+ background-color: #f4f4f4;
+ color: #bbb;
+ }
+
+.leaflet-touch .leaflet-bar a {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ }
+.leaflet-touch .leaflet-bar a:first-child {
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ }
+.leaflet-touch .leaflet-bar a:last-child {
+ border-bottom-left-radius: 2px;
+ border-bottom-right-radius: 2px;
+ }
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+ font: bold 18px 'Lucida Console', Monaco, monospace;
+ text-indent: 1px;
+ }
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
+ font-size: 22px;
+ }
+
+
+/* layers control */
+
+.leaflet-control-layers {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+ background: #fff;
+ border-radius: 5px;
+ }
+.leaflet-control-layers-toggle {
+ background-image: url(images/layers.png);
+ width: 36px;
+ height: 36px;
+ }
+.leaflet-retina .leaflet-control-layers-toggle {
+ background-image: url(images/layers-2x.png);
+ background-size: 26px 26px;
+ }
+.leaflet-touch .leaflet-control-layers-toggle {
+ width: 44px;
+ height: 44px;
+ }
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+ display: none;
+ }
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+ display: block;
+ position: relative;
+ }
+.leaflet-control-layers-expanded {
+ padding: 6px 10px 6px 6px;
+ color: #333;
+ background: #fff;
+ }
+.leaflet-control-layers-scrollbar {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ padding-right: 5px;
+ }
+.leaflet-control-layers-selector {
+ margin-top: 2px;
+ position: relative;
+ top: 1px;
+ }
+.leaflet-control-layers label {
+ display: block;
+ font-size: 13px;
+ font-size: 1.08333em;
+ }
+.leaflet-control-layers-separator {
+ height: 0;
+ border-top: 1px solid #ddd;
+ margin: 5px -10px 5px -6px;
+ }
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+ background-image: url(images/marker-icon.png);
+ }
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.8);
+ margin: 0;
+ }
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+ padding: 0 5px;
+ color: #333;
+ line-height: 1.4;
+ }
+.leaflet-control-attribution a {
+ text-decoration: none;
+ }
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+ text-decoration: underline;
+ }
+.leaflet-attribution-flag {
+ display: inline !important;
+ vertical-align: baseline !important;
+ width: 1em;
+ height: 0.6669em;
+ }
+.leaflet-left .leaflet-control-scale {
+ margin-left: 5px;
+ }
+.leaflet-bottom .leaflet-control-scale {
+ margin-bottom: 5px;
+ }
+.leaflet-control-scale-line {
+ border: 2px solid #777;
+ border-top: none;
+ line-height: 1.1;
+ padding: 2px 5px 1px;
+ white-space: nowrap;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.8);
+ text-shadow: 1px 1px #fff;
+ }
+.leaflet-control-scale-line:not(:first-child) {
+ border-top: 2px solid #777;
+ border-bottom: none;
+ margin-top: -2px;
+ }
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+ border-bottom: 2px solid #777;
+ }
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ box-shadow: none;
+ }
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ border: 2px solid rgba(0,0,0,0.2);
+ background-clip: padding-box;
+ }
+
+
+/* popup */
+
+.leaflet-popup {
+ position: absolute;
+ text-align: center;
+ margin-bottom: 20px;
+ }
+.leaflet-popup-content-wrapper {
+ padding: 1px;
+ text-align: left;
+ border-radius: 12px;
+ }
+.leaflet-popup-content {
+ margin: 13px 24px 13px 20px;
+ line-height: 1.3;
+ font-size: 13px;
+ font-size: 1.08333em;
+ min-height: 1px;
+ }
+.leaflet-popup-content p {
+ margin: 17px 0;
+ margin: 1.3em 0;
+ }
+.leaflet-popup-tip-container {
+ width: 40px;
+ height: 20px;
+ position: absolute;
+ left: 50%;
+ margin-top: -1px;
+ margin-left: -20px;
+ overflow: hidden;
+ pointer-events: none;
+ }
+.leaflet-popup-tip {
+ width: 17px;
+ height: 17px;
+ padding: 1px;
+
+ margin: -10px auto 0;
+ pointer-events: auto;
+
+ -webkit-transform: rotate(45deg);
+ -moz-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+ }
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+ background: white;
+ color: #333;
+ box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+ }
+.leaflet-container a.leaflet-popup-close-button {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: none;
+ text-align: center;
+ width: 24px;
+ height: 24px;
+ font: 16px/24px Tahoma, Verdana, sans-serif;
+ color: #757575;
+ text-decoration: none;
+ background: transparent;
+ }
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+ color: #585858;
+ }
+.leaflet-popup-scrolled {
+ overflow: auto;
+ }
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+ -ms-zoom: 1;
+ }
+.leaflet-oldie .leaflet-popup-tip {
+ width: 24px;
+ margin: 0 auto;
+
+ -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+ filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+ }
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+ border: 1px solid #999;
+ }
+
+
+/* div icon */
+
+.leaflet-div-icon {
+ background: #fff;
+ border: 1px solid #666;
+ }
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+ position: absolute;
+ padding: 6px;
+ background-color: #fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ color: #222;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ pointer-events: none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+ }
+.leaflet-tooltip.leaflet-interactive {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ position: absolute;
+ pointer-events: none;
+ border: 6px solid transparent;
+ background: transparent;
+ content: "";
+ }
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+ margin-top: 6px;
+}
+.leaflet-tooltip-top {
+ margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+ left: 50%;
+ margin-left: -6px;
+ }
+.leaflet-tooltip-top:before {
+ bottom: 0;
+ margin-bottom: -12px;
+ border-top-color: #fff;
+ }
+.leaflet-tooltip-bottom:before {
+ top: 0;
+ margin-top: -12px;
+ margin-left: -6px;
+ border-bottom-color: #fff;
+ }
+.leaflet-tooltip-left {
+ margin-left: -6px;
+}
+.leaflet-tooltip-right {
+ margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ top: 50%;
+ margin-top: -6px;
+ }
+.leaflet-tooltip-left:before {
+ right: 0;
+ margin-right: -12px;
+ border-left-color: #fff;
+ }
+.leaflet-tooltip-right:before {
+ left: 0;
+ margin-left: -12px;
+ border-right-color: #fff;
+ }
+
+/* Printing */
+
+@media print {
+ /* Prevent printers from removing background-images of controls. */
+ .leaflet-control {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+ }
diff --git a/rtg/interface/static/leaflet.js b/rtg/interface/static/leaflet.js
new file mode 100644
index 0000000..a3bf693
--- /dev/null
+++ b/rtg/interface/static/leaflet.js
@@ -0,0 +1,6 @@
+/* @preserve
+ * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
+ * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
+ */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng ","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML=' ',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0 ",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'+ ',zoomInTitle:"Zoom in",zoomOutText:'− ',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' | ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;e×',S(i,"click",function(t){O(t),this.close()},this))},_updateLayout:function(){var t=this._contentNode,e=t.style,i=(e.width="",e.whiteSpace="nowrap",t.offsetWidth),i=Math.min(i,this.options.maxWidth),i=(i=Math.max(i,this.options.minWidth),e.width=i+1+"px",e.whiteSpace="",e.height="",t.offsetHeight),n=this.options.maxHeight,o="leaflet-popup-scrolled";(n&&ns.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 input:not([type=radio]):not([type=checkbox]), .formulaire label > select { display: block; width: 100%; margin-top: 4px; }
+.formulaire label > .aide { display: block; font-weight: normal; }
+.formulaire label.choix { font-weight: normal; }
+.formulaire label.choix .aide { display: inline; }
+fieldset { border: 1px solid var(--bord); border-radius: 10px; margin: 16px 0; padding: 4px 16px 10px; background: var(--carte); }
+legend { font-weight: 600; padding: 0 6px; }
+form.en-ligne { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 4px 0; }
+
+.carte { height: 420px; border-radius: 10px; border: 1px solid var(--bord); margin-bottom: 12px; }
+details { margin: 16px 0; }
+summary { cursor: pointer; color: var(--accent); }
+
+.sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
+.bloc-source { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 4px 16px 14px; }
+.bloc-source ul { list-style: none; padding: 0; margin: 0 0 10px; }
+.bloc-source li { padding: 6px 0; border-bottom: 1px dashed var(--bord); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.bloc-source li code { flex: 1 1 100%; }
+.bloc-source li.manque code { text-decoration: line-through; opacity: .7; }
+.bloc-source .ajout form input[name=chemin] { flex: 1; min-width: 160px; }
+
+.fenetre-fond { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 2000; display: flex; align-items: center; justify-content: center; padding: 16px; }
+.fenetre { background: var(--carte); border-radius: 12px; padding: 16px 20px; width: min(640px, 100%); max-height: 85vh; display: flex; flex-direction: column; }
+.fenetre h3 { margin-top: 0; }
+.raccourcis { display: flex; gap: 6px; flex-wrap: wrap; }
+.liste-dossiers { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; border: 1px solid var(--bord); border-radius: 8px; min-height: 120px; }
+.liste-dossiers li a { display: block; padding: 6px 10px; text-decoration: none; color: var(--texte); }
+.liste-dossiers li a:hover { background: var(--neutre-fond); }
+.liste-dossiers li.vide { padding: 6px 10px; }
+.fenetre .pied { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
+
+.infos { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 12px; }
+.infos > div { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 12px; }
+.etiquette { display: block; font-size: 12px; color: var(--discret); text-transform: uppercase; letter-spacing: .4px; }
+
+.tache { background: var(--carte); border: 1px solid var(--bord); border-left: 4px solid var(--attention); border-radius: 10px; padding: 10px 14px; margin: 14px 0; }
+.tache.ok { border-left-color: var(--ok); }
+.tache.echec { border-left-color: var(--echec); }
+.tache-tete { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
+.tache-tete .danger { margin-left: auto; }
+.video-cours { margin: 8px 0; font-size: 14px; }
+progress { width: 100%; height: 8px; accent-color: var(--accent); }
+progress.global { height: 4px; opacity: .7; }
+.journal { background: #111; color: #ddd; font: 12px/1.45 ui-monospace, Menlo, monospace; padding: 10px; border-radius: 8px;
+ max-height: 420px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; }
+.journal.court { max-height: none; }
+
+@media (max-width: 700px) {
+ main { padding: 14px 16px 40px; }
+ .barre { padding: 10px 16px; gap: 10px; }
+ #bandeau { margin-left: 0; }
+ table.voyages th:nth-child(3), table.voyages td:nth-child(3) { display: none; }
+ th, td { padding: 6px 8px; }
+ .carte { height: 300px; }
+}
diff --git a/rtg/interface/static/rtg.js b/rtg/interface/static/rtg.js
new file mode 100644
index 0000000..7b25c18
--- /dev/null
+++ b/rtg/interface/static/rtg.js
@@ -0,0 +1,92 @@
+// Onglets, cartes Leaflet, fenêtre de choix de dossier, journal des traitements.
+(function () {
+ "use strict";
+
+ // --- Onglets (#journees, #sources…) --------------------------------------
+ function afficherOnglet() {
+ const liens = document.querySelectorAll(".onglets a");
+ if (!liens.length) return;
+ const nom = (location.hash || liens[0].getAttribute("href")).slice(1);
+ let trouve = false;
+ liens.forEach(a => {
+ const actif = a.getAttribute("href") === "#" + nom;
+ a.classList.toggle("actif", actif);
+ trouve = trouve || actif;
+ });
+ if (!trouve) { liens[0].classList.add("actif"); }
+ document.querySelectorAll(".onglet").forEach(s => {
+ s.classList.toggle("actif", s.id === "onglet-" + (trouve ? nom : liens[0].getAttribute("href").slice(1)));
+ });
+ document.querySelectorAll(".onglet.actif .carte").forEach(cadrer);
+ }
+ window.addEventListener("hashchange", afficherOnglet);
+ document.addEventListener("DOMContentLoaded", afficherOnglet);
+
+ // --- Cartes -----------------------------------------------------------------
+ // Une carte préparée dans un onglet caché n'a pas de taille : on la recadre
+ // la première fois qu'elle devient visible.
+ function cadrer(el) {
+ if (!el._carte || !el.offsetWidth) return;
+ el._carte.invalidateSize();
+ if (!el._cadree) {
+ el._cadree = true;
+ if (el._bornes) el._carte.fitBounds(el._bornes, { padding: [20, 20] });
+ else el._carte.setView([46.5, 2.5], 5);
+ }
+ }
+
+ function initCartes(racine) {
+ if (!window.L) return;
+ racine.querySelectorAll(".carte[data-couches]").forEach(el => {
+ if (el._carte) return;
+ const carte = L.map(el, { scrollWheelZoom: false });
+ el._carte = carte;
+ L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ maxZoom: 18, attribution: "© OpenStreetMap"
+ }).addTo(carte);
+ const groupe = L.featureGroup().addTo(carte);
+ JSON.parse(el.dataset.couches).forEach(c => {
+ c.segments.forEach(s => {
+ L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, dashArray: c.pointille ? "6 8" : null })
+ .bindTooltip(c.nom + (c.km ? " · " + c.km + " km" : ""), { sticky: true })
+ .addTo(groupe);
+ });
+ });
+ el._bornes = groupe.getLayers().length ? groupe.getBounds() : null;
+ carte.setView([46.5, 2.5], 5);
+ cadrer(el);
+ // La molette ne zoome qu'après un clic dans la carte (pas en faisant défiler la page).
+ carte.on("click", () => carte.scrollWheelZoom.enable());
+ carte.on("mouseout", () => carte.scrollWheelZoom.disable());
+ });
+ }
+
+ // --- Après chaque échange htmx --------------------------------------------
+ document.addEventListener("htmx:afterSettle", e => {
+ initCartes(e.detail.elt.parentElement || document);
+ document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
+ });
+ document.addEventListener("DOMContentLoaded", () => {
+ initCartes(document);
+ document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
+ });
+
+ // --- Fenêtre (choix de dossier) -------------------------------------------
+ function fermer() { document.getElementById("fenetre").innerHTML = ""; }
+ document.addEventListener("click", e => {
+ if (e.target.matches("[data-fermer-fond]")) fermer();
+ if (e.target.matches("[data-fermer]") && !e.target.hasAttribute("hx-post")) fermer();
+ });
+ document.addEventListener("htmx:afterRequest", e => {
+ if (e.detail.elt.matches && e.detail.elt.matches("[data-fermer]") && e.detail.successful) fermer();
+ });
+ document.addEventListener("keydown", e => { if (e.key === "Escape") fermer(); });
+
+ // Les messages (erreurs) s'effacent d'eux-mêmes.
+ document.addEventListener("htmx:afterSwap", e => {
+ if (e.detail.target.id === "messages") {
+ clearTimeout(window._rtgMessage);
+ window._rtgMessage = setTimeout(() => { e.detail.target.innerHTML = ""; }, 6000);
+ }
+ });
+})();
diff --git a/rtg/interface/taches.py b/rtg/interface/taches.py
new file mode 100644
index 0000000..ed6d206
--- /dev/null
+++ b/rtg/interface/taches.py
@@ -0,0 +1,125 @@
+"""Un traitement à la fois (génération, compression…) dans un fil à part.
+
+Tout ce que la chaîne affiche est recopié dans le journal de la tâche, en
+plus du terminal ; l'interface relit ce journal et l'avancement chaque seconde.
+"""
+import sys
+import threading
+import time
+import traceback
+from collections import deque
+
+from rtg.outils import ARRET, Interruption
+
+_verrou = threading.Lock()
+COURANTE = None
+
+
+class Tache:
+ def __init__(self, libelle, voyage=""):
+ self.libelle = libelle
+ self.voyage = voyage # chemin relatif, "" pour tout le site
+ self.lignes = deque(maxlen=3000)
+ self.etape = ""
+ self.etape_voyage = "" # voyage en cours quand tout le site est généré
+ self.video = None # {"nom", "pct", "rang": (i, n)}
+ self.debut = time.time()
+ self.fin = None
+ self.ok = None
+ self.arretee = False
+
+ @property
+ def en_cours(self):
+ return self.fin is None
+
+ @property
+ def duree(self):
+ s = int((self.fin or time.time()) - self.debut)
+ return f"{s // 60} min {s % 60:02d} s" if s >= 60 else f"{s} s"
+
+ def suivi(self, ev):
+ if "etape" in ev:
+ self.etape = ev["etape"]
+ self.etape_voyage = ev.get("voyage", "") if ev.get("voyage") != self.voyage else ""
+ if ev["etape"] != "Vidéos":
+ self.video = None
+ elif "video" in ev:
+ if ev["etat"] == "en_cours":
+ rang = ev.get("rang") or (self.video or {}).get("rang")
+ self.video = {"nom": ev["video"], "pct": ev["pct"] or 0, "rang": rang}
+ elif ev.get("rang"):
+ self.video = {"nom": ev["video"], "pct": 100, "rang": ev["rang"]}
+
+
+class _Recopie:
+ """Remplace sys.stdout pendant une tâche : écrit dans le terminal et dans
+ le journal de la tâche (ligne par ligne ; un « \\r » remplace la ligne)."""
+
+ def __init__(self, original, tache):
+ self.original, self.tache, self.tampon = original, tache, ""
+
+ def write(self, s):
+ self.original.write(s)
+ self.tampon += s
+ while "\n" in self.tampon:
+ ligne, self.tampon = self.tampon.split("\n", 1)
+ self.tache.lignes.append(ligne.rsplit("\r", 1)[-1])
+ return len(s)
+
+ def flush(self):
+ self.original.flush()
+
+ def isatty(self):
+ return False
+
+ def __getattr__(self, nom):
+ return getattr(self.original, nom)
+
+
+def lancer(libelle, fonction, voyage=""):
+ """Démarre `fonction(suivi)` dans un fil. Renvoie la tâche, ou None si une
+ autre tâche tourne encore."""
+ global COURANTE
+ with _verrou:
+ if COURANTE and COURANTE.en_cours:
+ return None
+ t = Tache(libelle, voyage)
+ COURANTE = t
+ ARRET.clear()
+
+ def executer():
+ original = sys.stdout
+ sys.stdout = _Recopie(original, t)
+ try:
+ res = fonction(t.suivi)
+ t.ok = res is not False
+ except Interruption:
+ t.ok, t.arretee = False, True
+ print("⏹ Traitement arrêté.")
+ except SystemExit as e:
+ t.ok = False
+ if e.code not in (None, 0):
+ print(str(e.code))
+ except Exception:
+ t.ok = False
+ print("❌ Erreur inattendue :")
+ print(traceback.format_exc())
+ finally:
+ if ARRET.is_set():
+ t.arretee = True
+ sys.stdout.flush()
+ sys.stdout = original
+ t.fin = time.time()
+ t.etape = "Arrêté" if t.arretee else ("Terminé" if t.ok else "Terminé avec des erreurs")
+ t.video = None
+ t.etape_voyage = ""
+
+ threading.Thread(target=executer, daemon=True, name="rtg-tache").start()
+ return t
+
+
+def arreter():
+ if COURANTE and COURANTE.en_cours:
+ ARRET.set()
+ return True
+ return False
diff --git a/rtg/outils.py b/rtg/outils.py
index 09a7222..2558c3e 100644
--- a/rtg/outils.py
+++ b/rtg/outils.py
@@ -3,10 +3,23 @@ import os
import shutil
import subprocess
import sys
+import threading
EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif")
EXT_VIDEOS = (".mp4", ".mov")
+# Demande d'arrêt d'un traitement en cours (bouton « Arrêter » de l'interface).
+ARRET = threading.Event()
+
+
+class Interruption(Exception):
+ pass
+
+
+def verifier_arret():
+ if ARRET.is_set():
+ raise Interruption()
+
def dire(message=""):
print(message, flush=True)
diff --git a/rtg/site.py b/rtg/site.py
index eca6f7d..cf8983a 100644
--- a/rtg/site.py
+++ b/rtg/site.py
@@ -4,7 +4,7 @@ import os
from rtg import accueil, extraction, pages, photos, sources, video
from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible,
reglages_site, reglages_voyage)
-from rtg.outils import dire, est_cache
+from rtg.outils import dire, est_cache, verifier_arret
IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web",
"apercus", "rushs", "routes"}
@@ -46,19 +46,28 @@ def _sources_disponibles(voyage, liste):
return True
-def generer_voyage(voyage, racine, site_cfg, compression=True, force=False):
+def generer_voyage(voyage, racine, site_cfg, compression=True, force=False, suivi=None):
+ """Toute la chaîne pour un voyage. `suivi(evenement)` (interface) reçoit
+ {"etape": …} au début de chaque étape et {"video": …} pendant la compression."""
cfg = reglages_voyage(voyage, site_cfg)
nom = os.path.relpath(voyage, racine)
dire("=" * 50)
dire(f"▶ {nom}")
dire("=" * 50)
+ def etape(libelle):
+ verifier_arret()
+ if suivi:
+ suivi({"etape": libelle, "voyage": nom})
+
+ etape("Sources")
a = sources.analyser(voyage, cfg)
for m in a.a_verifier:
dire(f"⚠️ {m}")
for r in sources.ranger(voyage, cfg, a):
dire(r)
+ etape("Positions")
extraction.extraire_photos(voyage, cfg, a)
if _sources_disponibles(voyage, cfg["sources"]["videos"]):
extraction.extraire_videos(voyage, a)
@@ -66,17 +75,24 @@ def generer_voyage(voyage, racine, site_cfg, compression=True, force=False):
dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.")
if compression and a.videos:
- video.compresser(voyage, cfg, a, force=force)
+ etape("Vidéos")
+ progression = None
+ if suivi:
+ def progression(fichier, etat, pct, rang):
+ suivi({"video": fichier, "etat": etat, "pct": pct, "rang": rang})
+ video.compresser(voyage, cfg, a, force=force, progression=progression)
elif not compression:
dire("⏭️ Compression sautée.")
+ etape("Photos")
photos.preparer(voyage, cfg, force=force)
+ etape("Pages")
ok = pages.generer(voyage, racine, cfg["adresse_publique"])
dire()
return ok
-def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False):
+def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False, suivi=None):
racine = os.path.abspath(racine)
cfg = reglages_site(racine)
liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine)
@@ -84,7 +100,10 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).")
return False
dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n")
- echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force)]
+ echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force, suivi)]
+ verifier_arret()
+ if suivi:
+ suivi({"etape": "Accueil", "voyage": ""})
dire("=" * 50)
dire("▶ Accueil")
dire("=" * 50)
@@ -94,32 +113,58 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
return not echecs
-def etat(racine):
- """Tableau de l'avancement de chaque voyage."""
+def rubrique_de(voyage, racine):
+ parent = os.path.dirname(os.path.abspath(voyage))
+ return "Voyages" if parent == os.path.abspath(racine) else nom_lisible(os.path.basename(parent))
+
+
+def etat_voyage(v, racine, site_cfg):
+ """Avancement d'un voyage (dictionnaire), sans rien lire de lourd."""
+ cfg = reglages_voyage(v, site_cfg)
+ a = sources.analyser(v, cfg, leger=True)
+ dossier = os.path.join(v, "videos")
+ faites_web = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
+ if a.videos:
+ faites = sum(1 for s in a.videos if video.deja_faite(v, s, rapide=True))
+ vid = f"{faites}/{len(a.videos)}"
+ else:
+ faites = None
+ vid = f"{faites_web} (sources absentes)" if faites_web else "—"
+ ph = os.path.join(v, "photos")
+ nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
+ pw = os.path.join(v, "photos_web")
+ nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
+ index = os.path.join(v, "index.html")
+ genere = os.path.exists(index)
+ pages_ok = genere and all(
+ os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
+ for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
+ if os.path.exists(os.path.join(v, c)))
+ jours = sorted(a.journees)
+ return {
+ "chemin": os.path.relpath(v, racine).replace(os.sep, "/"), "dossier": v,
+ "titre": cfg["titre"], "moto": cfg.get("moto", ""), "rubrique": rubrique_de(v, racine),
+ "debut": jours[0] if jours else None, "fin": jours[-1] if jours else None,
+ "jours": len(jours), "videos": vid, "videos_sources": len(a.videos),
+ "videos_faites": faites, "videos_web": faites_web,
+ "photos": nb_ph, "photos_web": nb_pw, "genere": genere, "pages_a_jour": pages_ok,
+ "a_verifier": a.a_verifier,
+ }
+
+
+def tableau_etat(racine):
racine = os.path.abspath(racine)
site_cfg = reglages_site(racine)
+ return [etat_voyage(v, racine, site_cfg) for v in decouvrir(racine)]
+
+
+def etat(racine):
+ """Tableau de l'avancement de chaque voyage."""
lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")]
- for v in decouvrir(racine):
- cfg = reglages_voyage(v, site_cfg)
- a = sources.analyser(v, cfg, leger=True)
- if a.videos:
- faites = sum(1 for s in a.videos if video.deja_faite(v, s, rapide=True))
- vid = f"{faites}/{len(a.videos)}"
- else:
- dossier = os.path.join(v, "videos")
- n = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
- vid = f"{n} (sources absentes)" if n else "—"
- ph = os.path.join(v, "photos")
- nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
- pw = os.path.join(v, "photos_web")
- nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
- index = os.path.join(v, "index.html")
- pages_ok = "à jour" if os.path.exists(index) and all(
- os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
- for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
- if os.path.exists(os.path.join(v, c))) else "à refaire"
- lignes.append((os.path.relpath(v, racine), str(len(a.journees)), vid,
- f"{nb_pw}/{nb_ph}" if nb_ph else "—", pages_ok, str(len(a.a_verifier)) or "0"))
+ for e in tableau_etat(racine):
+ lignes.append((e["chemin"], str(e["jours"]), e["videos"],
+ f"{e['photos_web']}/{e['photos']}" if e["photos"] else "—",
+ "à jour" if e["pages_a_jour"] else "à refaire", str(len(e["a_verifier"]))))
largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))]
for k, l in enumerate(lignes):
dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs)))
@@ -143,6 +188,25 @@ def analyser(voyage, racine):
dire(f" ⚠️ {m}")
+def creer_voyage(racine, dossier, titre, groupe="", moto=""):
+ """Nouveau voyage : /[/]/voyage.toml, sans source.
+ Renvoie le chemin du dossier créé."""
+ for nom in (dossier, groupe):
+ if nom and (os.sep in nom or "/" in nom or nom.startswith((".", "_")) or nom in IGNORES):
+ raise ValueError(f"Nom de dossier refusé : {nom}")
+ chemin = os.path.join(racine, groupe, dossier) if groupe else os.path.join(racine, dossier)
+ if os.path.exists(chemin):
+ raise ValueError(f"Le dossier {os.path.relpath(chemin, racine)} existe déjà")
+ os.makedirs(chemin)
+ donnees = {"titre": titre or nom_lisible(dossier)}
+ if moto:
+ donnees["moto"] = moto
+ donnees["sources"] = {"traces": [], "videos": [], "photos": []}
+ ecrire_toml(os.path.join(chemin, VOYAGE_TOML), donnees,
+ entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.")
+ return chemin
+
+
def migrer(racine):
"""Écrit site.toml et un voyage.toml par voyage à partir de l'existant
(mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent."""
diff --git a/rtg/sources.py b/rtg/sources.py
index 754ca42..b92d81e 100644
--- a/rtg/sources.py
+++ b/rtg/sources.py
@@ -11,10 +11,14 @@ Les vidéos ne sont pas copiées : la compression lit directement les sources.
Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/
(organisation historique) n'est pas touché.
"""
+import csv
+import math
import os
import re
import shutil
-from datetime import datetime, timedelta, timezone
+import subprocess
+from collections import Counter
+from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import gpxpy
@@ -33,6 +37,8 @@ class Analyse:
self.photos = [] # chemins des photos sources
self.videos = [] # chemins des vidéos sources
self.a_verifier = [] # messages pour l'utilisateur
+ self.details_traces = [] # un dict par fichier GPX (pour l'interface)
+ self.non_datees = {} # fichier → [GPXTrack] des traces sans jour
def km(self, jour):
total = 0.0
@@ -65,21 +71,36 @@ def date_du_nom(nom):
return None
-def decouper_par_journee(fichier, tz, coupure_h):
+def jour_texte(valeur):
+ """« 2025-08-04 » (ou date TOML) → date, sinon None."""
+ try:
+ return valeur if isinstance(valeur, date) else date.fromisoformat(str(valeur).strip())
+ except ValueError:
+ return None
+
+
+def decouper_par_journee(fichier, tz, coupure_h, jour_impose=None, pistes_sans_jour=None, infos=None):
"""Pistes d'un GPX réparties par journée locale.
Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est
- trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une
- coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille).
- Sans horodatage : la date du nom de fichier vaut pour tout le fichier.
+ trouvée. `jour_impose` (choisi dans l'interface) place tout le fichier sur
+ ce jour. Sinon, avec horodatages : date locale dans le fuseau du voyage,
+ avec une coupure à `coupure_h` heures (une étape finie à 1 h reste sur la
+ veille) ; sans horodatage : la date du nom de fichier. Les pistes d'un
+ fichier qu'aucune date ne place sont ajoutées à `pistes_sans_jour`. `infos`
+ (dictionnaire) reçoit "datable" : le fichier se date-t-il sans aide ?
"""
with open(fichier, encoding="utf-8") as f:
gpx = gpxpy.parse(f)
a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points)
+ if infos is not None:
+ infos["datable"] = a_des_heures or date_du_nom(os.path.basename(fichier)) is not None
res = {}
- if not a_des_heures:
- jour = date_du_nom(os.path.basename(fichier))
+ if jour_impose or not a_des_heures:
+ jour = jour_impose or date_du_nom(os.path.basename(fichier))
if jour is None:
+ if pistes_sans_jour is not None:
+ pistes_sans_jour.extend(gpx.tracks)
return {}, "trace sans heures et sans date dans son nom"
for t in gpx.tracks:
res.setdefault(jour, []).append(t)
@@ -120,21 +141,41 @@ def analyser(voyage, cfg, leger=False):
tz = ZoneInfo(cfg["journees"]["fuseau"])
coupure = int(cfg["journees"]["coupure_heure"])
- a.traces = [f for f in lister_fichiers(_sources(voyage, "traces", cfg), (".gpx",), voyage)
+ imposes = {k: jour_texte(v) for k, v in cfg.get("dates_traces", {}).items()}
+ sources_traces = _sources(voyage, "traces", cfg)
+ a.traces = [f for f in lister_fichiers(sources_traces, (".gpx",), voyage)
if os.path.basename(f) not in exclus]
+ if not _est_dossier_du_voyage(voyage, sources_traces, "routes"):
+ # Les journées que RTG a lui-même écrites dans routes/ ne sont pas des
+ # sources : les relire les compterait deux fois.
+ routes = os.path.realpath(os.path.join(voyage, "routes"))
+ a.traces = [f for f in a.traces
+ if os.path.realpath(os.path.dirname(f)) != routes or not ecrite_par_rtg(f)]
for f in a.traces:
- if leger and date_du_nom(os.path.basename(f)):
- a.journees.setdefault(date_du_nom(os.path.basename(f)), [])
+ nom = os.path.basename(f)
+ impose = imposes.get(nom)
+ if leger and (impose or date_du_nom(nom)):
+ a.journees.setdefault(impose or date_du_nom(nom), [])
continue
+ sans_jour, infos = [], {}
try:
- parts, raison = decouper_par_journee(f, tz, coupure)
+ parts, raison = decouper_par_journee(f, tz, coupure, impose, sans_jour, infos)
except Exception as e:
- a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})")
+ a.a_verifier.append(f"Trace illisible : {nom} ({e})")
+ a.details_traces.append({"fichier": f, "nom": nom, "jours": [], "km": 0,
+ "impose": impose, "erreur": str(e)})
continue
if raison:
- a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})")
+ a.a_verifier.append(f"Trace à rattacher à un jour : {nom} ({raison})")
+ a.non_datees[f] = sans_jour
+ km = 0.0
for jour, pistes in parts.items():
a.journees.setdefault(jour, []).extend(pistes)
+ km += sum((p.length_2d() or 0) for p in pistes) / 1000
+ if sans_jour:
+ km = sum((p.length_2d() or 0) for p in sans_jour) / 1000
+ a.details_traces.append({"fichier": f, "nom": nom, "jours": sorted(parts), "km": km,
+ "impose": impose, "erreur": None, "datable": infos["datable"]})
a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage)
if os.path.basename(f) not in exclus]
@@ -155,6 +196,11 @@ def analyser(voyage, cfg, leger=False):
return a
+def ecrite_par_rtg(fichier):
+ with open(fichier, encoding="utf-8", errors="replace") as f:
+ return f'creator="{CREATEUR}"' in f.read(600)
+
+
def ranger(voyage, cfg, a):
"""Range les sources dans le dossier du voyage (routes/, photos/).
Renvoie un résumé des écritures."""
@@ -187,9 +233,7 @@ def _ecrire_routes(voyage, a):
chemin = os.path.join(dossier, nom)
if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"):
continue
- with open(chemin, encoding="utf-8", errors="replace") as f:
- debut = f.read(600)
- if f'creator="{CREATEUR}"' in debut:
+ if ecrite_par_rtg(chemin):
os.remove(chemin)
retires += 1
return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "")
@@ -200,6 +244,8 @@ def _copier_photos(voyage, a):
os.makedirs(dossier, exist_ok=True)
copiees = converties = 0
for src in a.photos:
+ if os.path.realpath(os.path.dirname(src)) == os.path.realpath(dossier):
+ continue # déjà dans photos/
base, ext = os.path.splitext(os.path.basename(src))
heic = ext.lower() in (".heic", ".heif")
dest = os.path.join(dossier, base + (".jpeg" if heic else ext))
@@ -225,3 +271,61 @@ def convertir_heic(src, dest):
exif = im.info.get("exif")
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
os.replace(dest + ".tmp", dest)
+
+
+# --- Suggestion d'un jour pour une trace sans heures ---------------------------
+def positions_medias(voyage, a):
+ """[(date, lat, lon)] des photos et vidéos du voyage : exports CSV s'ils
+ existent, sinon lecture rapide des photos sources par exiftool."""
+ res = []
+ for nom in ("export_photos.csv", "export_videos.csv"):
+ chemin = os.path.join(voyage, nom)
+ if not os.path.exists(chemin):
+ continue
+ with open(chemin, encoding="utf-8", errors="replace") as f:
+ for ligne in csv.reader(f):
+ try:
+ res.append((date(*map(int, ligne[1][:10].replace("-", ":").split(":"))),
+ float(ligne[2]), float(ligne[3])))
+ except (ValueError, IndexError):
+ pass
+ if res or not a.photos or not shutil.which("exiftool"):
+ return res
+ r = subprocess.run(["exiftool", "-n", "-q", "-p", "$DateTimeOriginal,$GPSLatitude,$GPSLongitude"]
+ + a.photos[:2000], capture_output=True, text=True, errors="replace")
+ for ligne in r.stdout.splitlines():
+ try:
+ d, lat, lon = ligne.split(",")
+ res.append((date(*map(int, d[:10].split(":"))), float(lat), float(lon)))
+ except ValueError:
+ pass
+ return res
+
+
+def suggerer_jour(pistes, medias, rayon_km=1.5):
+ """Jour le plus probable pour une trace sans heures : celui dont le plus de
+ photos et vidéos ont été prises à moins de `rayon_km` de la trace.
+ Renvoie (date, nombre de médias) ou (None, 0)."""
+ pts = [(p.latitude, p.longitude) for t in pistes for s in t.segments for p in s.points]
+ if not pts or not medias:
+ return None, 0
+ pas = max(1, len(pts) // 2000)
+ pts = pts[::pas]
+ # Grille d'environ 1 km : on ne compare un média qu'aux points voisins.
+ cellule = 0.01
+ grille = {}
+ for lat, lon in pts:
+ grille.setdefault((math.floor(lat / cellule), math.floor(lon / cellule)), []).append((lat, lon))
+ portee = int(rayon_km / 1.1 / (cellule * 100)) + 1
+ compte = Counter()
+ for jour, lat, lon in medias:
+ i, j = math.floor(lat / cellule), math.floor(lon / cellule)
+ cos = math.cos(math.radians(lat))
+ proche = any(math.hypot((la - lat) * 111.2, (lo - lon) * 111.2 * cos) <= rayon_km
+ for di in range(-portee, portee + 1) for dj in range(-portee, portee + 1)
+ for la, lo in grille.get((i + di, j + dj), ()))
+ if proche:
+ compte[jour] += 1
+ if not compte:
+ return None, 0
+ return compte.most_common(1)[0]
diff --git a/rtg/video.py b/rtg/video.py
index 1c319ab..b1d455c 100644
--- a/rtg/video.py
+++ b/rtg/video.py
@@ -16,7 +16,7 @@ import time
from concurrent.futures import ThreadPoolExecutor
from rtg.config import parametres_video
-from rtg.outils import coeurs_performance, dire, exiger, lisible, taille_lisible
+from rtg.outils import ARRET, coeurs_performance, dire, exiger, lisible, taille_lisible
def _encodeurs_disponibles():
@@ -126,8 +126,9 @@ def deja_faite(voyage, src, rapide=False):
def compresser(voyage, cfg, analyse, force=False, progression=None):
- """Compresse les rushs du voyage. `progression(nom, etat, pourcentage)` est
- appelée à chaque changement (pour l'interface). Renvoie True si tout a réussi."""
+ """Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
+ est appelée à chaque changement (pour l'interface) ; rang = (i, total).
+ Renvoie True si tout a réussi."""
videos = analyse.videos
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
p = parametres_video(cfg)
@@ -148,9 +149,9 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
verrou = threading.Lock()
bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0}
- def signaler(nom, etat, detail=None):
+ def signaler(nom, etat, detail=None, rang=None):
if progression:
- progression(nom, etat, detail)
+ progression(nom, etat, detail, rang)
total = len(videos)
en_direct = jobs == 1 and sys.stdout.isatty()
@@ -160,15 +161,17 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
nom = os.path.basename(src)
rang = f"[{i:3d}/{total}]"
hd, sd = sorties(voyage, src)
+ if ARRET.is_set():
+ return False
if not force and lisible(hd) and lisible(sd):
with verrou:
bilan["sautes"] += 1
dire(f"⏭️ {rang} {nom} — déjà faite")
- signaler(nom, "deja_faite")
+ signaler(nom, "deja_faite", rang=(i, total))
return True
h, duree = _hauteur(src), _duree(src)
dire(f"🎬 {rang} {nom} ({h}p, {int(duree) // 60} min {int(duree) % 60:02d} s) — encodage…")
- signaler(nom, "en_cours", 0)
+ signaler(nom, "en_cours", 0, rang=(i, total))
tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd))
tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
avancement = {"hd": 0.0, "sd": 0.0}
@@ -183,11 +186,14 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
dernier = -1
while any(pr.poll() is None for pr in procs):
time.sleep(0.5)
+ if ARRET.is_set():
+ for pr in procs:
+ pr.terminate()
moyenne = int((avancement["hd"] + avancement["sd"]) / 2)
if en_direct:
print(f"\r 1080p {avancement['hd']:3.0f} % · 720p {avancement['sd']:3.0f} % ",
end="", flush=True)
- elif moyenne // 25 > dernier // 25 and moyenne < 100:
+ elif 0 < moyenne < 100 and moyenne // 25 > max(dernier, 0) // 25:
dire(f" {nom} : {moyenne} %")
if moyenne != dernier:
signaler(nom, "en_cours", moyenne)
@@ -211,6 +217,10 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
for t in (tmp_hd, tmp_sd):
if os.path.exists(t):
os.remove(t)
+ if ARRET.is_set():
+ dire(f" ⏹ {nom} : arrêtée")
+ signaler(nom, "arretee")
+ return False
with verrou:
bilan["echecs"] += 1
dire(f" ❌ Échec sur {nom}")
@@ -235,4 +245,6 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))")
if bilan["echecs"]:
dire(f"⚠️ {bilan['echecs']} échec(s)")
+ if ARRET.is_set():
+ dire("⏹ Compression arrêtée (les vidéos finies sont gardées).")
return all(resultats)
diff --git a/tests/test_interface.py b/tests/test_interface.py
new file mode 100644
index 0000000..75aacf9
--- /dev/null
+++ b/tests/test_interface.py
@@ -0,0 +1,100 @@
+"""Interface : jours imposés aux traces sans heures, suggestion d'après les
+photos, et quelques échanges avec le serveur local."""
+import os
+import threading
+import urllib.parse
+import urllib.request
+from datetime import date
+from http.server import ThreadingHTTPServer
+
+import pytest
+
+from rtg import config, sources
+from rtg.interface import serveur
+from test_chaine import _photo, pytestmark # noqa: F401 (mêmes prérequis)
+
+
+def _etape(chemin, lat0, lon0):
+ pts = "".join(f' ' for i in range(50))
+ with open(chemin, "w") as f:
+ f.write(f'{pts} ')
+
+
+@pytest.fixture
+def voyage_sans_heures(tmp_path):
+ racine = tmp_path / "site"
+ voyage = racine / "Ecosse-2027"
+ gpx, photos = tmp_path / "gpx", tmp_path / "photos"
+ for d in (voyage, gpx, photos):
+ d.mkdir(parents=True)
+ _etape(gpx / "Etape 1.gpx", 55.9, 3.2)
+ _etape(gpx / "Etape 2.gpx", 56.7, 2.24)
+ _photo(str(photos / "P1.jpg"), "2027:06:11 10:00:00", gps=(56.75, 2.30))
+ config.ecrire_toml(str(racine / "site.toml"), {"titre": "Test"})
+ config.ecrire_toml(str(voyage / "voyage.toml"), {
+ "titre": "Écosse", "sources": {"traces": [str(gpx)], "videos": [], "photos": [str(photos)]}})
+ return racine, voyage
+
+
+def _cfg(racine, voyage):
+ return config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
+
+
+def test_jour_impose_et_suggestion(voyage_sans_heures):
+ racine, voyage = voyage_sans_heures
+ a = sources.analyser(str(voyage), _cfg(racine, voyage))
+ assert not a.journees and len(a.non_datees) == 2
+ medias = sources.positions_medias(str(voyage), a)
+ par_nom = {os.path.basename(f): p for f, p in a.non_datees.items()}
+ assert sources.suggerer_jour(par_nom["Etape 2.gpx"], medias) == (date(2027, 6, 11), 1)
+ assert sources.suggerer_jour(par_nom["Etape 1.gpx"], medias) == (None, 0)
+
+ config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10"}})
+ a = sources.analyser(str(voyage), _cfg(racine, voyage))
+ assert sorted(a.journees) == [date(2027, 6, 10)]
+ assert [os.path.basename(f) for f in a.non_datees] == ["Etape 2.gpx"]
+ # Le titre et les sources ont survécu à la réécriture du fichier
+ assert config.lire_toml(str(voyage / "voyage.toml"))["titre"] == "Écosse"
+
+
+@pytest.fixture
+def client(voyage_sans_heures):
+ racine, voyage = voyage_sans_heures
+ app = serveur.Application(str(racine))
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
+ port = srv.server_address[1]
+ srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port)
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+
+ def appel(chemin, donnees=None, htmx=True):
+ req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}",
+ data=urllib.parse.urlencode(donnees).encode() if donnees else None)
+ if htmx:
+ req.add_header("HX-Request", "true")
+ try:
+ with urllib.request.urlopen(req) as r:
+ return r.status, r.read().decode()
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode()
+ yield appel, racine, voyage
+ srv.shutdown()
+
+
+def test_serveur(client):
+ appel, racine, voyage = client
+ code, html = appel("/")
+ assert code == 200 and "Écosse" in html
+ code, html = appel("/voyage/analyse?v=Ecosse-2027")
+ assert "2 traces à rattacher" in html and "11 juin 2027" in html
+ # Répartir : un jour par trace dans l'ordre des noms
+ code, html = appel("/voyage/dates-suite", {"v": "Ecosse-2027", "debut": "2027-06-10"})
+ assert "2 journées" in html
+ assert config.lire_toml(str(voyage / "voyage.toml"))["dates_traces"] == {
+ "Etape 1.gpx": "2027-06-10", "Etape 2.gpx": "2027-06-11"}
+ # Requête de modification sans l'en-tête htmx : refusée
+ code, _ = appel("/voyage/date", {"v": "Ecosse-2027", "fichier": "Etape 1.gpx", "jour": ""}, htmx=False)
+ assert code == 403
+ # Voyage hors du site : refusé
+ code, html = appel("/voyage/analyse?v=../../etc")
+ assert "introuvable" in html.lower()