From 06153a227e53514558a183bdee254ccdfc925640 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 9 Oct 2026 14:07:47 +0000 Subject: [PATCH] =?UTF-8?q?Interface=20locale=20:=20rtg=20interface=20(voy?= =?UTF-8?q?ages,=20sources,=20journ=C3=A9es,=20r=C3=A9cit,=20g=C3=A9n?= =?UTF-8?q?=C3=A9ration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serveur web local sans dépendance nouvelle (http.server, Jinja2, htmx et Leaflet fournis). Écrans : Mes voyages, nouveau voyage, un voyage (journées avec carte, rattachement des traces sans heures à un jour avec suggestion d'après les photos, sources avec choix de dossier, infos et récit, génération avec avancement et arrêt), réglages du site et vérification des outils. Chaîne : jours imposés ([dates_traces] dans voyage.toml), arrêt propre de la compression, suivi des étapes, état structuré (site.tableau_etat), titre du voyage repris de voyage.toml sur l'accueil. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim --- README.md | 23 +- pyproject.toml | 3 +- rtg/accueil.py | 2 +- rtg/cli.py | 8 + rtg/config.py | 43 +- rtg/interface/__init__.py | 1 + rtg/interface/modeles/_analyse.html | 94 +++ rtg/interface/modeles/_bandeau.html | 5 + rtg/interface/modeles/_dossiers.html | 27 + rtg/interface/modeles/_infos_utiles.html | 12 + rtg/interface/modeles/_sources.html | 31 + rtg/interface/modeles/_systeme.html | 2 + rtg/interface/modeles/_tache.html | 24 + rtg/interface/modeles/base.html | 31 + rtg/interface/modeles/nouveau.html | 28 + rtg/interface/modeles/reglages.html | 68 ++ rtg/interface/modeles/tache.html | 11 + rtg/interface/modeles/voyage.html | 68 ++ rtg/interface/modeles/voyages.html | 63 ++ rtg/interface/serveur.py | 670 ++++++++++++++++++ rtg/interface/static/LICENCES.txt | 32 + rtg/interface/static/htmx.min.js | 1 + rtg/interface/static/images/layers-2x.png | Bin 0 -> 1259 bytes rtg/interface/static/images/layers.png | Bin 0 -> 696 bytes .../static/images/marker-icon-2x.png | Bin 0 -> 2464 bytes rtg/interface/static/images/marker-icon.png | Bin 0 -> 1466 bytes rtg/interface/static/images/marker-shadow.png | Bin 0 -> 618 bytes rtg/interface/static/leaflet.css | 661 +++++++++++++++++ rtg/interface/static/leaflet.js | 6 + rtg/interface/static/rtg.css | 147 ++++ rtg/interface/static/rtg.js | 92 +++ rtg/interface/taches.py | 125 ++++ rtg/outils.py | 13 + rtg/site.py | 120 +++- rtg/sources.py | 136 +++- rtg/video.py | 28 +- tests/test_interface.py | 100 +++ 37 files changed, 2618 insertions(+), 57 deletions(-) create mode 100644 rtg/interface/__init__.py create mode 100644 rtg/interface/modeles/_analyse.html create mode 100644 rtg/interface/modeles/_bandeau.html create mode 100644 rtg/interface/modeles/_dossiers.html create mode 100644 rtg/interface/modeles/_infos_utiles.html create mode 100644 rtg/interface/modeles/_sources.html create mode 100644 rtg/interface/modeles/_systeme.html create mode 100644 rtg/interface/modeles/_tache.html create mode 100644 rtg/interface/modeles/base.html create mode 100644 rtg/interface/modeles/nouveau.html create mode 100644 rtg/interface/modeles/reglages.html create mode 100644 rtg/interface/modeles/tache.html create mode 100644 rtg/interface/modeles/voyage.html create mode 100644 rtg/interface/modeles/voyages.html create mode 100644 rtg/interface/serveur.py create mode 100644 rtg/interface/static/LICENCES.txt create mode 100644 rtg/interface/static/htmx.min.js create mode 100644 rtg/interface/static/images/layers-2x.png create mode 100644 rtg/interface/static/images/layers.png create mode 100644 rtg/interface/static/images/marker-icon-2x.png create mode 100644 rtg/interface/static/images/marker-icon.png create mode 100644 rtg/interface/static/images/marker-shadow.png create mode 100644 rtg/interface/static/leaflet.css create mode 100644 rtg/interface/static/leaflet.js create mode 100644 rtg/interface/static/rtg.css create mode 100644 rtg/interface/static/rtg.js create mode 100644 rtg/interface/taches.py create mode 100644 tests/test_interface.py 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 %} + + + + + + {% endfor %} + +
{{ nom }} {{ d.km|round|int }} km +
+ + +
+
+ {% set s = suggestions.get(nom) %} + {% if s and s[0] %} + + {% else %}pas de suggestion{% endif %} +
+ {% if non_datees|length > 1 %} +
+ + Ou bien, dans l'ordre des noms, un jour par trace à partir du + +
+ {% endif %} +
+ {% endif %} + +

{{ jours|length }} journée{{ 's' if jours|length > 1 }} · {{ total_km|round|int }} km

+ + + + {% for j in jours %} + + + + {% endfor %} + +
JourkmTraces
{{ j.date|date_longue }}{{ j.km|round|int }}{{ j.traces|join(", ") }}
+ +
+ 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.

+ + + + {% for d in a.details_traces %} + + + + + + {% endfor %} + +
FichierJour(s) retenu(s)Jour imposé
{{ d.nom }}{% if d.erreur %} illisible{% endif %}{% for j in d.jours %}{{ j|date_courte }}{{ ", " if not loop.last }}{% else %}—{% endfor %} +
+ + + +
+ {% if d.impose %} + + {% endif %} +
+
+{% 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 @@ +
+ +
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 %} + +
  • + {% else %} +
  • Aucune source
  • + {% endfor %} +
+
+ +
+ + + + + +
+
+
+{% 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 %} + + {% 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

+
+ {% if erreur %}
{{ erreur }}
{% endif %} + + + +
+ Rubrique + + {% for g in groupes %} + + {% endfor %} + +
+

Annuler

+

Le dossier est créé avec un voyage.toml. Vous indiquerez ensuite où sont les traces, les vidéos et les photos : elles restent où elles sont.

+
+{% 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.

+
+
+ Site + + + +
+ +
+ Vidéos +

Chaque rush donne une version 1080p HEVC (H.265) et une version 720p H.264, lue par défaut et partout.

+ {% set descriptions = {"econome": "fichiers plus légers, légère perte visible sur les scènes chargées", + "equilibree": "réglage d'origine, très bon compromis", + "haute": "presque indiscernable de l'original, fichiers nettement plus gros"} %} + {% for q, p in qualites.items() %} + + {% endfor %} + + + +
+ +
+ Photos + + + +
+ +
+ Journées + + +
+ +
+ Carte globale + +
+

+
+ +

Système

+

Vérification des outils…

+{% 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 }}

+
+
+ {% if v.genere %}Voir la page ↗{% endif %} +
+
+ + + +
+
+

Lecture des traces…

+
+
+ +
+

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" %} +
+ +
+
+ + + +

Rubrique : {{ v.rubrique }} (elle vient du dossier où est rangé le voyage).

+

+
+
+ + +

Liens spéciaux : [Carte du jour](jour:2026-08-01) ouvre la carte d'un jour, + [Le col](media:photos/IMG_4831.jpeg) un média du voyage.

+

+
+
+ +
+

Calcul des tailles…

+
+ + + +

+ + +

+
+
{% if tache and tache.voyage == v.chemin %}{% include "_tache.html" %}{% endif %}
+
+{% 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 }}.

+

+

Pour un autre dossier, relancez avec rtg interface --racine CHEMIN.

+
+{% endif %} + +
+

Mes voyages {{ nb }}

+
+ + Nouveau voyage +
+ + +
+
+
+
+ +{% for g, liste in groupes %} +
+

{{ g }} {{ liste|length }}

+ + + + {% for v in liste %} + + + + + + + + + + {% endfor %} + +
VoyageDatesJoursVidéos webPhotos webPages
{{ 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 %}
+
+{% 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/",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(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",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 0000000000000000000000000000000000000000..200c333dca9652ac4cba004d609e5af4eee168c1 GIT binary patch literal 1259 zcmVFhCYNy;#0irRPomHqW|G1C*;4?@4#E?jH>?v@U%cy?3dQAc-DchXVErpOh~ z-jbon+tNbnl6hoEb;)TVk+%hTDDi_G%i3*RZ&15!$Fjr^f;Ke&A@|?=`2&+{zr+3a z{D*=t(`AXyS%X7N z%a#RZw6vD^t_rnM`L4E>m=U&R!A-&}nZIi$BOPvkhrCuUe@BN~-lRD)f44;J%TwgE zcze8u!PQ_NR7?o(NylLXVTfDO zxs5=@|GsYEsNo4M#nT%N!UE(?dnS)t2+{ELYAFp*3=iF=|EQnTp`#vlSXuGVraYo? z+RCzXo6h3qA8{KG?S4nE(lM+;Eb4nT3XV;7gcAxUi5m)`k5tv}cPy()8ZR3TLW3I- zAS^}cq-IJvL7a4RgR!yk@~RT%$lA7{L5ES*hyx)M4(yxI$Ub(4f)K|^v1>zvwQY!_ zIrWw8q9GS^!Dp~}+?mbnB6jDF8mVlbQ!jFKDY;w=7;XO{9bq7>LXGK24WA`;rL)_Z z)&j}pbV(;6gY;VMhbxgvn`X;6x}VUEE-7 z%)7j-%t8S=ZL3yc)HbXDAqJZvBTPoiW_A-+a8m3_Z?v{DN7Tnr#O_VUMT0UBt$;p` zDh6JbGHN8JJ*JN%y2%msb97@_S>9!%Egwk;?PEkU9ntz&3uR}%Fj5d$JHQbQb3}a{ zSzFT^#n=VInPpcAS}CNxj?_ zVscANk5Cfz(51EI1pz};AWWb|kgbYNb4wCEGUn3+eMUMV?1-{=I4TlmLJMot@rd07 zZuo2hk1ccu{YmGkcYdWAVdk{Z4Nm?^cTD&}jGm+Q1SYIXMwmG*oO*83&#>l%nbR`G zhh=lZ%xIb7kU3#;TBbfECrnC9P=-XpL|TG2BoZdj61*XiFbW8?1Z_wp%#;>${SUIy V$8qr;L*)Pf002ovPDHLkV1hYLS~36t literal 0 HcmV?d00001 diff --git a/rtg/interface/static/images/layers.png b/rtg/interface/static/images/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..1a72e5784b2b456eac5d7670738db80697af3377 GIT binary patch literal 696 zcmV;p0!RIcP)*@&l2<6p=!C&s@#ZL+%BQvF&b?w6S%wp=I>1QHj7AP5C)IWy#b znXXB;g;j=$a-tW89K%FbDceHVq&unY*Wx3L#=EGWH=rjqnp|4c_Ulec!ql3#G-5ZF zVlbBA@XP=)C8U&+Lrc)S4O5%1$&{(;7R^K(CSnvSr$v;+B$8q&7Bf|h$#PARo1^%M zf1H^nG-EiXVXr07OH(*8R)xa|FD;lXUlg_-%)~ZGsL2cX0NXaAzN2q%jqLRR6ruVk8`Jb7n#{`T;o@`F= z#3YcynIR^s83UNF3D!f5m#Mg)NJ24&Qfrqb&_z=yF;=B)#9Iq7u-@^O!(mW{D;qvr zPc)gVb%aowtS8m@ElL4A9G>w#ffQ~q{i&_i)*6f^)Sz|C?C>zb4Uo?H<-&Hz@a?J; z$ml@zGygWofb9$ZBj6aLjpLhsT2AzjOu=-*u_gSCUYnU^5s62$4H-fe}gSR(=wKRaTHh!@*b)YV6mo|a4Fn6Rgc&Rpk zvn_X|3VY?v=>nJ{slE^V1GaGWk}m@aIWGIpghbfPh8m@aIWEo_%AZI>==moIFVE^L=C zZJ91?mo03UEp3-BY?wBGur6$uD{Yr9Y?m%SHF8Fk1pc(Nva%QJ+{FLkalfypz3&M|||Fn`7|g3c~4(nXHKFmRnwn$J#_$xE8i z|Ns9!kC;(oC1qQk>LMp3_a2(odYyMT@>voX=UI)k>1cJdn;gjmJ-|6v4nb1Oryh)eQMwHP(i@!36%vGJyFK(JTj?Vb{{C=jx&)@1l zlFmnw%0`&bqruifkkHKC=vbiAM3&E`#Mv>2%tw;VK8?_|&E89cs{a1}$J*!f_xd-C z&F%B|oxRgPlh0F!txkxrQjNA`m9~?&&|jw4W0<`_iNHsX$VQXVK!B}Xkh4>av|f_8 zLY2?t?ejE=%(TnfV5iqOjm?d;&qI~ZGl|SzU77a)002XDQchC<95+*MjE@82?VLm= z3xf6%Vd@99z|q|-ua5l3kJxvZwan-8K1cPiwQAtlcNX~ZqLeoMB+a;7)WA|O#HOB% zg6SX;754xD1{Fy}K~#8Ntklac&zTpadXZ& zC*_=T&g7hfbI$R?v%9?sknIb97gJOJ=`-8YyS3ndqN+Jm+x33!p&Hc@@L$w))s2@N ztv~i}Emc?DykgwFWwma($8+~b>l?tqj$dh13R^nMZnva9 zn0Vflzv2Dvp`oVQw{Guby~i`JGbyBGTEC{y>yzCkg>K&CIeQ$u;lyQ+M{O~gEJ^)Z zrF3p)^>|uT;57}WY&IRwyOQ=dq%Az}_t=_hKowP!Z79q0;@Zu(SWEJJcHY+5T6I({ zw)wj*SNi4wrd+POUfZe4gF77vW?j zoFS}|r2n&$U9Y!S4VEOyN}OpZZi|?cr1VcE_tHsDQgp-ga(SwkBrkCm{|*-yb=}ZW zvcYvLvfA90TPn|!-TuYJV<6`}+RJeRgP3EA=qQcF9k0*#*{f&I_pjam%I6Dd#YE|G zqB!R}tW-K!wV1w+4JcFA_s6~=@9F&j8`u$-ifLN3vK;`lvaA-`jRn_}(8|)!3?-}I zvFi{H;@A$gEZYh?%|Qr_y#*UkOPjwiRCsJQ>mb6h5yGIk6C5_XA=8T?IBfm_?+P0; zhhUs)-(0R*H<&Kku(1>#cGtOpk&Z&kQcw&SJv-4VY<+;=8hYnoX zfNJMCa9)^5Z0;2dCUk;x-%#yS!I~Jr3pNuI!g_tHz!$hKwt1GL~sFvx)3u4TA zv>CLGdQtoZ7Du7ctJRfTqY;FPxs1G{ZJ?73D5J@OO{6BHcPbk{_mjg&p2QFeke%QI zlAJ-kvjuwy1<5D-6>su68A+i998aSZNnQX)+Q}6(GK-C%8G-!1bOJBONU{gT%IOOE z;Yk24YC@^lFW77>r6x7eS1Omc;8=GUp#&zLQ&L{ zv8$hGC`wp~$9pR>f%-_Ps3>YhzP(+vC(E*zr1CVO8ChN^MI-VGMX7+|(r!SGZ9gd5 zzO9sQd>sm|f1|X&oh=8lOzd6+ITvo zCXInR?>RZ#>Hb*PO=7dI!dZ(wY4O}ZGv zdfQFio7+0~PN*RFCZGM6@9-o~y*@?;k00NvOsw54t1^tt{*ATMs^2j}4Wp=4t3RH* z_+8b`F-{E=0sOgM<;VHTo!Ij3u zmmI`2?K7g(GOcGA)@h?$SW&pwHdtj1n57PLI8&6RHhx4R%Q7b z^JEqR)@06V!pbS*@D_ZyRMo_LlT}r{#sXOx4kM-V<_V{!5SSuM^SIVCA37|nY7LWQ zZA#B1h4l`6asz=Lvax_#GMRX|NF>=$=p{Qn0i@ExX1jGhy@B8a*_uR+ODEbVi8ObL zezG?azy>E~S~dl43&8<$(2H}P&*tuBdESUP83KQ?8B z?K(!uS>H1wlWQz;qOfB`T#TZ=EoSp~vZ5XtCvwm1h*Ex6mzTsn_y@_=xREIslV-%- zpdWkEzMjeNOGWrSM32gpBt27*O29NdhGzuDgYxcf`Jjjqw@B;Vmdb@fxdhCRi`Kg> zmUTr$=&@#i!%F4Q6mb&4QKfR^95KJ!<6~fqx-f^66AV!|ywG{6D^Vay-3b99>XOe# e-I|>x8~*?ZhF3snGbtJX0000cOl4 literal 0 HcmV?d00001 diff --git a/rtg/interface/static/images/marker-icon.png b/rtg/interface/static/images/marker-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..950edf24677ded147df13b26f91baa2b0fa70513 GIT binary patch literal 1466 zcmV;r1x5OaP)P001cn1^@s6z>|W`000GnNklGNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1 z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlft#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTnMpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*`;(#{U^d-vY)&xEE>n5lYl&T?Amke9$Lam@{1K@O ze*LXqlKQHiv=gx+V^Cbb2?z@ISBQ*3amF;9UJ3SBg(N|710TLamQmYZ&Qjn2LuO<* zCZlB4n%@pc&7NNnY1}x+NWpHlq`OJEo|`aYN9<`RBUB+79g;>dgb6YlfN#kGL?lO_ z!6~M^7sOnbsUkKk<@Ysie&`G>ruxH&Mgy&8;i=A zB9OO!xR{AyODw>DS-q5YM{0ExFEAzt zm>RdS+ssW(-8|?xr0(?$vBVB*%(xDLtq3Hf0I5yFm<_g=W2`QWAax{1rWVH=I!VrP zs(rTFX@W#t$hXNvbgX`gK&^w_YD;CQ!B@e0QbLIWaKAXQe2-kkloo;{iF#6}z!4=W zi$giRj1{ zt;2w`VSCF#WE&*ev7jpsC=6175@(~nTE2;7M-L((0bH@yG}-TB$R~WXd?tA$s3|%y zA`9$sA(>F%J3ioz<-LJl*^o1|w84l>HBR`>3l9c8$5Xr@xCiIQ7{x$fMCzOk_-M=% z+{a_Q#;42`#KfUte@$NT77uaTz?b-fBe)1s5XE$yA79fm?KqM^VgLXD07*qoM6N<$ Ef<_J(9smFU literal 0 HcmV?d00001 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;es.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()