From 23969c9e143efa87b9195dd37a3a1c1f99f6c3c9 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Fri, 4 Sep 2026 21:36:14 +0200 Subject: [PATCH] Add Leaflet interactive map, tile generator compose, auto-sync cache, deploy docs index.py rewritten as a continuous Leaflet map: rotated L93 tiles, stackable visualization layers (per-layer opacity, drag-reorder persisted in localStorage), tile info panel, live rebuild after each tile during a run. Leaflet is vendored in assets/vendor/ so the map works fully offline; georeferencing falls back rasterio -> pyproj -> affine so the lightweight webapp (no GDAL) is supported. docker-compose.worker.yml adds the tile generator service (full image + GPU) that remote webapps call via LIDAR_GENERATION_URL, plus a one-shot process profile. webapp.py gains LIDAR_AUTO_SYNC_SECONDS periodic cache refresh and LIDAR_REGEN_CIDR restricting generation to the local network. run.sh --serve-webapp now mounts ~/.ssh read-only so the rsync sync works. docs/DEPLOY_WEBAPP.md completed for Raspberry Pi deployment: prerequisites, git clone install, SSH key setup, first sync, update procedure and troubleshooting. --- AGENTS.md | 1 + docker-compose.webapp.yml | 17 +- docker-compose.worker.yml | 57 ++ docs/DEPLOY_WEBAPP.md | 161 ++- .../vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes .../assets/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes .../vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes .../vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes .../vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes .../assets/vendor/leaflet/leaflet.css | 661 ++++++++++++ .../assets/vendor/leaflet/leaflet.js | 6 + lidar_pipeline/index.py | 942 ++++++++++++------ lidar_pipeline/tests/test_index.py | 34 + lidar_pipeline/tests/test_webapp.py | 78 ++ lidar_pipeline/webapp.py | 123 ++- run.sh | 79 +- setup.py | 5 + 17 files changed, 1828 insertions(+), 336 deletions(-) create mode 100644 docker-compose.worker.yml create mode 100644 lidar_pipeline/assets/vendor/leaflet/images/layers-2x.png create mode 100644 lidar_pipeline/assets/vendor/leaflet/images/layers.png create mode 100644 lidar_pipeline/assets/vendor/leaflet/images/marker-icon-2x.png create mode 100644 lidar_pipeline/assets/vendor/leaflet/images/marker-icon.png create mode 100644 lidar_pipeline/assets/vendor/leaflet/images/marker-shadow.png create mode 100644 lidar_pipeline/assets/vendor/leaflet/leaflet.css create mode 100644 lidar_pipeline/assets/vendor/leaflet/leaflet.js diff --git a/AGENTS.md b/AGENTS.md index f1d64a3..4278e0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ - install: `docker build -t lidar-lidar .` (deps baked into image) - build: `docker build -t lidar-lidar .` - build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU) +- build générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes) - test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord) - test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.` - test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.::::` diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml index 736e68a..f178f48 100644 --- a/docker-compose.webapp.yml +++ b/docker-compose.webapp.yml @@ -2,7 +2,7 @@ # # La carte et les vignettes sont servies localement ; la génération de tuiles # (téléchargement IGN + traitement PDAL/GPU) est déléguée à la machine de -# traitement = service `serve` du docker-compose.yml de cette machine. +# traitement = service `worker` du docker-compose.worker.yml de cette machine. # # docker compose -f docker-compose.webapp.yml up -d --build # docker compose -f docker-compose.webapp.yml logs -f webapp @@ -27,11 +27,22 @@ services: environment: - TZ=Europe/Paris - LIDAR_OUTPUT_DIR=/data/output - # Machine de traitement (service `serve` de son docker-compose.yml) - - LIDAR_GENERATION_URL=http://192.168.1.50:8973 + # Adresse du backend de génération des tuiles (machine de traitement, + # service `worker` de son docker-compose.worker.yml) — à définir : + # LIDAR_GENERATION_URL=http://:8973 + # Sans cette variable : consultation autonome du cache local, aucune + # génération possible depuis l'interface. + # - LIDAR_GENERATION_URL=http://192.168.1.50:8973 # Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement # - LIDAR_REMOTE_TOKEN=change-moi # Ramène les tuiles traitées + sidecars DTM (les vignettes sont # régénérées localement par /api/sync, ne pas les synchroniser) # - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ + # Cache local auto-entretenu : resynchronisation + vignettes toutes les + # N secondes (0/défaut = seulement à la demande, bouton ↻ ou /api/sync) + # - LIDAR_AUTO_SYNC_SECONDS=600 + # Réseau autorisé à lancer les générations depuis la carte (défaut + # 192.168.1.0/24 ; chaîne vide = restriction levée). Les autres clients + # consultent la carte en lecture seule (boutons masqués, API 403). + # - LIDAR_REGEN_CIDR=192.168.1.0/24 restart: unless-stopped diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml new file mode 100644 index 0000000..9f5a35d --- /dev/null +++ b/docker-compose.worker.yml @@ -0,0 +1,57 @@ +# Machine de traitement — générateur de tuiles (cf. docs/DEPLOY_WEBAPP.md). +# +# Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les +# webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent : +# dessin d'une zone → téléchargement IGN + traitement GPU ici, les tuiles +# sont relues par la webapp via son LIDAR_SYNC_CMD (rsync). +# +# docker compose -f docker-compose.worker.yml up -d --build +# docker compose -f docker-compose.worker.yml logs -f worker +# docker compose -f docker-compose.worker.yml down +# +# Traitement batch ponctuel des dalles présentes dans input/ : +# docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,0.2 | --force] +services: + # Générateur de tuiles : API de génération (webapp complète, GPU) + worker: + build: . + image: lidar-lidar + container_name: lidar-worker + init: true + user: "1000:1000" + gpus: all # retirer cette ligne sur une machine sans GPU + ports: + - "8973:8973" # joignable par la webapp distante (LIDAR_GENERATION_URL) + volumes: + # input/ en écriture : l'API y télécharge les dalles IGN manquantes + - ./input:/data/input + - ./output:/data/output + environment: + - TZ=Europe/Paris + - LIDAR_INPUT_DIR=/data/input + - LIDAR_OUTPUT_DIR=/data/output + # Les générations lancées depuis une webapp distante utilisent le GPU + - LIDAR_GPU=1 + - LIDAR_WORKERS=2 + # Protéger l'API si le réseau n'est pas de confiance : même valeur que + # LIDAR_REMOTE_TOKEN sur chaque webapp distante (sinon, laisser commenté) + # - LIDAR_API_TOKEN=change-moi + command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 + restart: unless-stopped + + # Traitement ponctuel des dalles input/ (une passe puis arrêt) + process: + build: . + image: lidar-lidar + container_name: lidar-process + init: true + user: "1000:1000" + gpus: all # retirer cette ligne sur une machine sans GPU + volumes: + - ./input:/data/input + - ./output:/data/output + environment: + - TZ=Europe/Paris + command: ["python3", "-m", "lidar_pipeline", "/data/input", "-o", "/data/output", "-r", "0.5,0.2", "-g", "all"] + profiles: + - process diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index 8ea4071..7d2640d 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -12,21 +12,32 @@ Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp) │ └─ transmis à ──▶ machine de traitement │ /api/sync : rsync output/ ──◀── machine puissante ▼ - Machine puissante (image complète, docker-compose.yml - service `serve`) : téléchargement IGN + pipeline GPU + Machine de traitement (image complète, + docker-compose.worker.yml service `worker`) : + téléchargement IGN + pipeline GPU (générateur de tuiles) ``` -## Machine de traitement (puissante) +## Machine de traitement (générateur de tuiles) -Le service `serve` existant joue le rôle de worker : il accepte les demandes -de génération envoyées par la webapp du Pi. +Le service `worker` joue le rôle de générateur : il accepte les demandes +de génération envoyées par les webapp distantes (téléchargement IGN + +traitement PDAL/GPU) et expose les tuiles produites pour le rsync. ```bash -docker compose up -d --build serve # carte + API sur http://:8973 +docker compose -f docker-compose.worker.yml up -d --build # API sur http://:8973 +docker compose -f docker-compose.worker.yml logs -f worker ``` -Optionnel mais recommandé si le réseau n'est pas de confiance : protéger les -routes mutantes avec un jeton partagé — décommenter dans `docker-compose.yml` : +Traitement batch ponctuel des dalles déjà présentes dans `input/` : + +```bash +docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,0.2 | --force] +``` + +Sur une machine sans GPU : retirer les lignes `gpus: all` (traitement CPU, +plus lent). Optionnel mais recommandé si le réseau n'est pas de confiance : +protéger l'API avec un jeton partagé — décommenter dans +`docker-compose.worker.yml` : ```yaml environment: @@ -35,26 +46,85 @@ environment: ## Raspberry Pi (webapp légère) -### 1. Copier le code sur le Pi +### 0. Prérequis sur le Pi + +- **Architecture** : image construite nativement sur la machine (ARM64 ou + x86_64, vérifier avec `uname -m`). Le build se fait sur le Pi lui-même + (`Dockerfile.webapp` : base `python:3.12-slim`, ~200 Mo, sans PDAL/GDAL). +- **Docker + plugin compose** : installation officielle + [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) + (tester avec `docker compose version`). +- **rsync/ssh côté client** : inutile sur l'hôte — ils sont dans l'image — + mais la **clé SSH** de l'hôte est montée dans le conteneur. +- **Espace disque** : prévoir la taille du cache de tuiles (compter la + taille de `output/` sur la machine de traitement, ~quelques dizaines de + Mo par dalle et par résolution). + +### 1. Copier le code sur le Pi (git clone) ```bash -git clone lidar && cd lidar # ou rsync du poste de dev +git clone ssh://git@git.example.fr:2222/code_public/lidar_rendu.git lidar +cd lidar ``` +Le clone SSH utilise la même clé que le rsync (`~/.ssh`, voir ci-dessous) ; +si le serveur git n'est pas encore connu, faire une première connexion pour +accepter son empreinte. `output/` et `input/` sont ignorés par git : le +dépôt ne contient que le code, le cache de tuiles se remplit ensuite par +rsync (premier chargement, section 4). + ### 2. Accès SSH pour le rsync (la Pi tire les tuiles traitées) ```bash ssh-keygen -t ed25519 # si pas encore de clé ssh-copy-id lidar@ # compte lecture sur output/ +ssh lidar@ exit # 1re connexion : enregistre known_hosts ``` +La dernière commande évite le prompt « authenticity of host » pendant le +rsync (le conteneur ne peut pas répondre interactivement). + Sur la machine puissante, le dossier `output/` doit être lisible par ce compte (ex. `/srv/lidar/output` si vous préférez un chemin dédié — adaptez LIDAR_SYNC_CMD). +Avec `run.sh`, `~/.ssh` (clé + known_hosts) est monté automatiquement en +lecture seule dans le conteneur ; avec `docker compose`, le montage +équivalent est à décommenter dans `docker-compose.webapp.yml` (voir +l'option b ci-dessous). + ### 3. Configurer et lancer -Éditer `docker-compose.webapp.yml` : +Deux façons, au choix : + +**a. `run.sh` (le plus simple)** — conteneur webapp seul, cache local des +tuiles ; les variables se passent depuis l'environnement hôte, `~/.ssh` +est monté automatiquement : + +```bash +LIDAR_GENERATION_URL=http://192.168.1.50:8973 \ +LIDAR_REMOTE_TOKEN=un-secret-à-partager \ +LIDAR_SYNC_CMD="rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/" \ +LIDAR_AUTO_SYNC_SECONDS=600 \ +./run.sh --serve-webapp # port 8973, ou --serve-webapp 9000 +``` + +`LIDAR_AUTO_SYNC_SECONDS` entretient le cache tout seul : toutes les N +secondes, rsync ramène les nouvelles tuiles et les vignettes manquantes +sont régénérées (les mtimes évitent tout recalcul inutile). Sans cette +variable, le cache se rafraîchit à la demande : bouton ↻, fin d'un run, +ou `POST /api/sync`. + +`LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST +/api/generate` : zones, complétion, régénération) aux clients dont l'IP +est dans le réseau indiqué — défaut `192.168.1.0/24`, chaîne vide pour +lever la restriction. L'IP est celle de la connexion (conservée par le +DNAT Docker pour les clients du LAN) ; derrière un reverse proxy local +(dans le réseau autorisé), `X-Forwarded-For` désigne le client réel. +Les autres clients consultent la carte normalement : les boutons de +génération sont masqués et l'API répond 403. + +**b. `docker compose`** — éditer `docker-compose.webapp.yml` : - `LIDAR_GENERATION_URL` : `http://:8973` - `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine @@ -66,6 +136,14 @@ LIDAR_SYNC_CMD). - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ ``` +Décommenter aussi le montage de la clé SSH (rsync) : + +```yaml + volumes: + - ./output:/data/output + - ~/.ssh:/home/lidar/.ssh:ro +``` + Les vignettes (`index_thumbs/`, `index_subtiles/`) ne se synchronisent pas : elles sont régénérées sur place par le Pi (`/api/sync` → `build_index`), c'est le seul travail lourd qu'il fait. Exclure `*.tif` évite de copier des @@ -79,6 +157,10 @@ docker compose -f docker-compose.webapp.yml up -d --build La carte est sur `http://:8973/`. +`run.sh` lance le conteneur au premier plan (Ctrl-C arrête tout) : pratique +pour tester. Pour une installation permanente, préférer `docker compose` +(`restart: unless-stopped` : la carte revient après un reboot du Pi). + ### 4. Premier chargement Si aucune tuile n'a encore été synchronisée, déclencher une première fois : @@ -90,6 +172,42 @@ curl -X POST http://:8973/api/sync (puis attendre la fin : `curl http://:8973/api/sync` → `"running": false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes). +## Mise à jour + +Sur le Pi, après un `git pull` (ou rsync du code) : + +```bash +docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage +``` + +Avec `run.sh --serve-webapp`, l'image est reconstruite automatiquement à +chaque lancement ; il suffit de relancer la commande. Le cache de tuiles +(`output/`) n'est pas affecté par les mises à jour d'image. + +## Dépannage + +- **État d'une sync** : `curl http://:8973/api/sync` → `running`, + `phase` (`sync` puis `index`) et `error` (dernière erreur, ex. fin de + journal rsync en cas d'échec). Les logs du conteneur : + `docker logs lidar-webapp` (ou `docker compose -f docker-compose.webapp.yml + logs -f webapp`). +- **`Permission denied (publickey)` pendant la sync** : refaire + `ssh-copy-id lidar@` et une connexion manuelle pour + renseigner `known_hosts` ; vérifier que `~/.ssh` est monté dans le + conteneur (automatique avec `run.sh`, à décommenter en compose). +- **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du + client n'est pas dans `LIDAR_REGEN_CIDR` (défaut `192.168.1.0/24`). + Adapter la variable ou la vider pour lever la restriction. +- **« Machine de traitement injoignable »** : vérifier le service `worker` + (`docker compose -f docker-compose.worker.yml logs -f worker`) et + `LIDAR_GENERATION_URL` (IP + port 8973). La consultation de la carte + reste possible avec le cache local. +- **Carte vide après sync** : attendre la fin du rebuild (`GET /api/sync` → + `"running": false`) puis recharger la page ; le bouton ↻ de la carte + enchaîne sync + vignettes automatiquement. +- **Port 8973 déjà pris** : `--serve-webapp 9000` avec `run.sh`, ou changer + le mapping `ports:` en compose (le conteneur écoute toujours sur 8973). + ## Fonctionnement - Dessiner une zone (bouton « + Zone ») sur la carte du Pi envoie la demande @@ -98,9 +216,19 @@ false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes). machine distante en direct. - À la fin du run, le navigateur appelle `/api/sync` : rsync ramène les images, le Pi régénère vignettes/sous-tuiles/index.html, puis recharge la - carte. Le bouton ↻ relance le même cycle à tout moment. + carte. Le bouton ↻ relance le même cycle à tout moment. Avec + `LIDAR_AUTO_SYNC_SECONDS`, le conteneur webapp seul maintient aussi son + cache périodiquement (utile quand personne ne consulte la carte). +- Sans machine de traitement configurée, `./run.sh --serve-webapp` sert la + carte en lecture seule depuis `output/` (cache figé, aucun traitement + possible depuis l'interface). +- **Autonomie** : une fois les tuiles chargées dans le cache local, la + webapp fonctionne seule — Leaflet est vendorisé dans `assets/vendor/` + (aucun CDN), tuiles/vignettes/infos sont lues dans `output/`, et un + backend injoignable masque simplement les boutons de génération sans + affecter la consultation. - Machine de traitement seule (sans Pi) : rien ne change, `--serve` / - `docker compose up serve` fonctionne comme avant en local. + `docker compose up -d --build serve` fonctionne comme avant en local (tout-en-un), et `docker-compose.worker.yml` installe le générateur seul. - Si la machine de traitement est éteinte, la carte du Pi reste consultable (données synchronisées) ; seuls les nouveaux traitements sont indisponibles (message « machine de traitement injoignable »). @@ -108,8 +236,11 @@ false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes). ## Références - `lidar_pipeline/webapp.py` : proxy distant (`LIDAR_GENERATION_URL`), - `/api/sync`, jeton `LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`. + `/api/sync`, cache local périodique (`LIDAR_AUTO_SYNC_SECONDS`), jeton + `LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`, restriction des générations au + réseau local (`LIDAR_REGEN_CIDR`). - `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli affine), recharge après sync. -- `Dockerfile.webapp`, `docker-compose.webapp.yml` : image légère ARM64 +- `docker-compose.worker.yml` : générateur de tuiles (machine puissante). +- `Dockerfile.webapp`, `docker-compose.webapp.yml` : webapp légère ARM64 (FastAPI + Pillow AVIF natif + pyproj, ~200 Mo). diff --git a/lidar_pipeline/assets/vendor/leaflet/images/layers-2x.png b/lidar_pipeline/assets/vendor/leaflet/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/lidar_pipeline/assets/vendor/leaflet/images/layers.png b/lidar_pipeline/assets/vendor/leaflet/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/lidar_pipeline/assets/vendor/leaflet/images/marker-icon.png b/lidar_pipeline/assets/vendor/leaflet/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/lidar_pipeline/assets/vendor/leaflet/leaflet.css b/lidar_pipeline/assets/vendor/leaflet/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/lidar_pipeline/assets/vendor/leaflet/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/lidar_pipeline/assets/vendor/leaflet/leaflet.js b/lidar_pipeline/assets/vendor/leaflet/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/lidar_pipeline/assets/vendor/leaflet/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= src_mtime -def generate_thumbnail(src_path, thumb_path, max_size=256): +def generate_thumbnail(src_path, thumb_path, max_size=256, mid_path=None, + mid_size=640): """Génère une vignette JPEG depuis une image AVIF/WebP existante. Args: src_path: chemin de l'image source (AVIF/WebP). thumb_path: chemin de sortie JPEG. max_size: taille maximale (côté le plus grand) en pixels. + mid_path: vignette intermédiaire optionnelle (JPEG, taille mid_size). + mid_size: taille maximale de la vignette intermédiaire. Returns: - True si OK, False si échec. + True si la vignette principale est OK, False si échec. """ try: from PIL import Image as PILImage @@ -437,19 +445,27 @@ def generate_thumbnail(src_path, thumb_path, max_size=256): return False try: + try: + resample = PILImage.Resampling.LANCZOS + except AttributeError: + resample = getattr(PILImage, 'LANCZOS', 1) + + def resized(source, target): + scale = min(1.0, target / max(source.size)) + if scale >= 1.0: + return source + return source.resize((max(1, int(source.size[0] * scale)), + max(1, int(source.size[1] * scale))), resample) + img = PILImage.open(str(src_path)) img = img.convert('RGB') - w, h = img.size - scale = min(1.0, max_size / max(w, h)) - if scale < 1.0: - new_size = (max(1, int(w * scale)), max(1, int(h * scale))) - try: - resample = PILImage.Resampling.LANCZOS - except AttributeError: - resample = getattr(PILImage, 'LANCZOS', 1) - img = img.resize(new_size, resample) Path(thumb_path).parent.mkdir(parents=True, exist_ok=True) - img.save(str(thumb_path), format='JPEG', quality=80) + if mid_path is not None: + try: + resized(img, mid_size).save(str(mid_path), format='JPEG', quality=82) + except Exception as e: + logger.debug(f"Vignette intermédiaire ignorée {src_path}: {e}") + resized(img, max_size).save(str(thumb_path), format='JPEG', quality=80) return True except Exception as e: logger.debug(f"Vignette ignorée {src_path}: {e}") @@ -549,6 +565,7 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): all_exist = all( _cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime) and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime) + and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_mid.webp'), src_mtime) for stem in stems.values()) if not all_exist: logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} " @@ -578,6 +595,13 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): return None # Supprime l'ancien crop .webp d'une génération précédente (output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True) + mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size)) + mid_img = quad + if mid_scale < 1.0: + mid_img = quad.resize((max(1, int(quad.size[0] * mid_scale)), + max(1, int(quad.size[1] * mid_scale))), resample) + mid_img.save(str(output_dir / sub_dir_name / (stem + '_mid.webp')), + format='WEBP', quality=82) scale = min(1.0, 256 / max(quad.size)) if scale < 1.0: quad = quad.resize((max(1, int(quad.size[0] * scale)), @@ -587,6 +611,7 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): for (i, j), stem in stems.items(): entries[(i, j)]['viz'][viz_key] = { 'thumb': f"{sub_dir_name}/{stem}_thumb.webp", + 'mid': f"{sub_dir_name}/{stem}_mid.webp", 'full': f"{sub_dir_name}/{stem}.avif", } except Exception as e: @@ -757,11 +782,16 @@ def build_index(output_dir, output_format='avif'): # Les couches fusionnées d'une autre résolution vivent dans # leur dossier d'origine (info['dir_name']), pas dir_path. src = _viz_src_dir(t, info) / info['filename'] + src_mtime = _mtime(src) thumb_name = f"{t['dir_name']}_{viz_key}.jpg" thumb_path = thumb_dir / thumb_name + mid_name = f"{t['dir_name']}_{viz_key}_mid.jpg" + mid_path = thumb_dir / mid_name # Régénère si manquante ou périmée (tuile recalculée depuis) - if not _cached_file_fresh(thumb_path, _mtime(src)): - if generate_thumbnail(src, thumb_path): + if (not _cached_file_fresh(thumb_path, src_mtime) + or not _cached_file_fresh(mid_path, src_mtime)): + if generate_thumbnail(src, thumb_path, mid_path=mid_path, + mid_size=_MID_THUMB_SIZE): thumbs_generated += 1 regen += 1 else: @@ -774,6 +804,8 @@ def build_index(output_dir, output_format='avif'): 'thumb': f"index_thumbs/{thumb_name}", 'full': f"visualisations/{viz_dir_name}/{info['filename']}", } + if mid_path.is_file(): + viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}" if regen: logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — " @@ -855,11 +887,21 @@ def build_index(output_dir, output_format='avif'): def _write_assets(output_dir): - """Écrit les fichiers statiques de l'interface (CSS + JS) dans output/assets/.""" + """Écrit les fichiers statiques de l'interface (CSS + JS) dans output/assets/. + + Inclut Leaflet vendorisé (assets/vendor/leaflet) : la carte reste + fonctionnelle hors ligne, sans dépendance au CDN unpkg. + """ + import shutil assets_dir = Path(output_dir) / 'assets' assets_dir.mkdir(parents=True, exist_ok=True) (assets_dir / 'app.css').write_text(_APP_CSS, encoding='utf-8') (assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8') + src_vendor = Path(__file__).parent / 'assets' / 'vendor' + dst_vendor = assets_dir / 'vendor' + if src_vendor.is_dir(): + shutil.rmtree(dst_vendor, ignore_errors=True) + shutil.copytree(src_vendor, dst_vendor) def _panel_selection(all_viz_keys): @@ -893,12 +935,13 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format): # Options du sélecteur de génération/régénération : uniquement les couches # réellement proposées dans le panneau de visualisation (mêmes couches, - # mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Repli sur - # aspect si aucune couche n'est encore disponible. + # mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Toutes + # pré-sélectionnées : une régénération refait la dalle complète du panneau, + # l'utilisateur pouvant désélectionner pour cibler. Repli sur aspect si + # aucune couche n'est encore disponible. gen_keys = ordered_viz if ordered_viz else ['aspect'] gen_viz_options = "\n".join( - f' ' for v in gen_keys) @@ -932,120 +975,135 @@ _HTML_TEMPLATE = """ Carte LiDAR - +
-
-

Carte LiDAR

- {n_tiles} tuile(s) · {grid_w}×{grid_h} km · {output_format} -
+ + - - - -
Zoom
Molette : zoom · Clic-glisser : déplacer
Clic sur tuile : infos de génération
- -
- - - - - - - -
- - - - - + """ -_APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */ +_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */ :root { --bg: #0b0e13; - --panel: rgba(16, 20, 27, 0.9); + --panel: rgba(16, 20, 27, 0.93); --panel-border: rgba(255, 255, 255, 0.09); --text: #d9dee6; --muted: #8b94a3; @@ -1055,30 +1113,39 @@ _APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */ --success: #4cc98a; --success-dim: #2f8f63; --radius: 10px; + --sbw: 320px; /* largeur de la barre latérale */ } * { box-sizing: border-box; margin: 0; padding: 0; } +[hidden] { display: none !important; } html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); font-size: 13px; } +/* Décalage des contrôles ancrés à gauche (échelle Leaflet, puce zoom) + selon la présence de la barre latérale. */ +body { --left-ui: calc(var(--sbw) + 24px); } +body.sb-collapsed { --left-ui: 12px; } #viewport { position: absolute; inset: 0; background: var(--bg); } .leaflet-container { background: var(--bg); font: inherit; } -/* Fond OSM assombri pour faire ressortir les tuiles LiDAR */ -#viewport .leaflet-tile-pane { filter: grayscale(0.6) brightness(0.5) contrast(1.2); } -#viewport.bright .leaflet-tile-pane { filter: none; } +/* Fond OSM assombri pour faire ressortir les tuiles LiDAR : simple opacité + sur le tile-pane (un filter CSS forcerait sa rastérisation complète à + chaque frame de pan/zoom — coûteux) */ +#viewport .leaflet-tile-pane { opacity: 0.45; } +#viewport.bright .leaflet-tile-pane { opacity: 1; } +#viewport .leaflet-bottom.leaflet-left { left: var(--left-ui); } -/* --- Panneaux flottants --- */ +/* --- Panneaux : socle visuel commun (unité de l'interface) --- */ .panel { position: fixed; z-index: 900; background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); color: var(--text); } @@ -1087,7 +1154,10 @@ html, body { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--panel-border); } -.panel-title { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } +.panel-title { + font-size: 12px; font-weight: 600; letter-spacing: 0.04em; + text-transform: uppercase; color: var(--muted); +} button { background: rgba(255, 255, 255, 0.06); color: var(--text); @@ -1105,43 +1175,63 @@ button:disabled { opacity: 0.4; cursor: not-allowed; } select, input[type="checkbox"] { accent-color: var(--accent); } .gen-select { background: rgba(255, 255, 255, 0.06); color: var(--text); - border: 1px solid var(--panel-border); border-radius: 4px; + border: 1px solid var(--panel-border); border-radius: 6px; padding: 3px 6px; font-size: 12px; cursor: pointer; } -/* --- En-tête (marque + stats) --- */ -#brand { top: 12px; left: 12px; padding: 8px 14px; } -#brand h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; } -#brand .stat { font-size: 11.5px; color: var(--muted); } - -/* --- Outils carte (haut droite) --- */ -#mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; } -#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; } -#mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); } -#mapTools #btnGenZone:hover { background: var(--accent); } -#mapTools #btnComplete { - width: auto; padding: 0 14px; font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em; - background: var(--success-dim); border-color: var(--success-dim); color: #eafff4; - box-shadow: 0 2px 10px rgba(76, 201, 138, 0.25); +/* --- Barre latérale (gauche, pleine hauteur) --- */ +#sidebar { + top: 12px; bottom: 12px; left: 12px; width: var(--sbw); + display: flex; flex-direction: column; } -#mapTools #btnComplete:hover { background: var(--success); border-color: var(--success); box-shadow: 0 2px 14px rgba(76, 201, 138, 0.45); } -#mapTools #btnComplete .ok-ic { margin-right: 6px; font-weight: 700; } +.sidebar-head { padding: 10px 12px 8px; border-bottom: 1px solid var(--panel-border); } +.head-row { display: flex; align-items: center; justify-content: space-between; } +#sidebar h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; } +#sidebar .stat { display: block; margin-top: 2px; font-size: 11px; color: var(--muted); } +#sidebarCollapse { color: var(--muted); border: none; background: none; } +#sidebarCollapse:hover { color: var(--text); } +#sidebar.collapsed { bottom: auto; } +#sidebar.collapsed .sidebar-body { display: none; } +#sidebar.collapsed #sidebarCollapse { transform: rotate(180deg); } -/* --- Panneau de couches (gauche) --- */ -#layerPanel { top: 74px; left: 12px; width: 300px; } -#layerPanel.collapsed #layerBody { display: none; } -#layerPanel.collapsed #layerCollapse { transform: rotate(-90deg); } -#layerCollapse { border: none; background: none; color: var(--muted); } -#layerBody { padding: 6px; max-height: calc(100vh - 200px); overflow-y: auto; } +.sidebar-body { flex: 1; min-height: 0; display: flex; flex-direction: column; } +/* Onglets : contrôle segmenté */ +.tabbar { + display: flex; gap: 4px; margin: 8px; padding: 3px; + background: rgba(255, 255, 255, 0.05); border-radius: 8px; +} +.tab { + flex: 1; border: none; background: transparent; border-radius: 6px; + padding: 6px 8px; font-size: 12px; font-weight: 600; color: var(--muted); + display: inline-flex; align-items: center; justify-content: center; gap: 6px; +} +.tab:hover { background: rgba(255, 255, 255, 0.07); color: var(--text); border-color: transparent; } +.tab.active { background: var(--accent-dim); color: #fff; } +.tab-badge { + min-width: 16px; padding: 0 4px; border-radius: 999px; + background: var(--accent); color: #06263f; + font-size: 10px; font-weight: 700; line-height: 15px; +} + +.tab-page { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 8px 10px; } + +/* --- Lignes de couches --- */ .layer-row { - display: grid; grid-template-columns: 26px 1fr 74px 36px; + display: grid; grid-template-columns: 16px 24px minmax(0, 1fr) 80px 30px; align-items: center; gap: 6px; padding: 5px 6px; border-radius: 6px; + user-select: none; } +#osmRow { grid-template-columns: 24px minmax(0, 1fr) 80px 30px; } .layer-row:hover { background: rgba(255, 255, 255, 0.05); } .layer-row.dragging { opacity: 0.45; } -.layer-row[data-layer] { cursor: grab; } + +.layer-grip { + color: var(--muted); cursor: grab; font-size: 13px; + text-align: center; line-height: 1; +} +.layer-grip:hover, .layer-row.dragging .layer-grip { color: var(--accent); } .eye { width: 24px; height: 24px; padding: 0; border: none; background: none; @@ -1159,24 +1249,37 @@ select, input[type="checkbox"] { accent-color: var(--accent); } .layer-pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } .layer-sub { + display: flex; align-items: center; justify-content: space-between; font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 6px 4px; - border-top: 1px solid var(--panel-border); margin-top: 4px; + border-top: 1px solid var(--panel-border); margin-top: 6px; } +/* Sliders d'opacité : piste fine dans une zone cliquable haute de 18 px — + le pouce reste saisissable au drag (l'élément lui-même fait toute la + hauteur, pas seulement la piste de 4 px). */ input[type="range"] { -webkit-appearance: none; appearance: none; - width: 100%; height: 3px; border-radius: 2px; - background: rgba(255, 255, 255, 0.15); outline: none; cursor: pointer; + width: 100%; height: 18px; background: transparent; + outline: none; cursor: pointer; margin: 0; +} +input[type="range"]::-webkit-slider-runnable-track { + height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.18); } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 11px; height: 11px; border-radius: 50%; - background: var(--accent); border: none; + width: 13px; height: 13px; border-radius: 50%; + background: var(--accent); margin-top: -4.5px; + border: 2px solid #0b0e13; + box-shadow: 0 0 0 1px rgba(90, 169, 230, 0.4); +} +input[type="range"]:hover::-webkit-slider-thumb { background: #7ec4f5; } +input[type="range"]::-moz-range-track { + height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.18); } input[type="range"]::-moz-range-thumb { - width: 11px; height: 11px; border-radius: 50%; - background: var(--accent); border: none; + width: 10px; height: 10px; border-radius: 50%; + background: var(--accent); border: 2px solid #0b0e13; } /* --- Tuiles LiDAR sur la carte --- */ @@ -1187,7 +1290,12 @@ input[type="range"]::-moz-range-thumb { /* Fond transparent obligatoire : l'opacité des couches doit laisser voir les couches du dessous et le fond de carte, pas un rectangle noir. */ background: transparent; + /* Layout et peinture des tuiles hors écran skippés par le navigateur */ + content-visibility: auto; } +/* Uniquement pendant l'animation de zoom (classe posée par app.js) : + scaling sur le GPU, puis libération pour ne pas immobiliser de mémoire. */ +body.zooming .ltile { will-change: transform; } .ltile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; @@ -1197,40 +1305,64 @@ input[type="range"]::-moz-range-thumb { .ltile:hover { border-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } .ltile.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } -/* --- Panneau d'infos tuile (droite) --- */ -#infoPanel { top: 60px; right: 12px; width: 330px; } -#infoBody { padding: 10px 12px; max-height: calc(100vh - 180px); overflow-y: auto; } -#infoClose { border: none; background: none; color: var(--muted); font-size: 18px; } -#infoClose:hover { color: var(--danger); } - -.meta-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin-bottom: 10px; } -.meta-list dt { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px; } -.meta-list dd { font-size: 12.5px; } -.meta-list dd.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; } - -.info-sub { - font-size: 10.5px; color: var(--muted); text-transform: uppercase; - letter-spacing: 0.05em; margin: 10px 0 4px; - border-top: 1px solid var(--panel-border); padding-top: 8px; +/* --- Fiche tuile (onglet Tuile) --- */ +.tile-info { padding: 6px 6px 10px; } +.tile-info-head { display: flex; align-items: center; gap: 8px; } +.tile-info-title { font-size: 13px; font-weight: 700; } +.tile-info-badge { + font-size: 10.5px; color: var(--accent); background: rgba(90, 169, 230, 0.12); + border-radius: 999px; padding: 1px 8px; font-variant-numeric: tabular-nums; +} +.tile-info-close { margin-left: auto; color: var(--muted); border: none; background: none; font-size: 16px; } +.tile-info-close:hover { color: var(--danger); } +.tile-empty { + padding: 20px 12px; color: var(--muted); font-size: 12px; + line-height: 1.5; text-align: center; +} +.tile-info-dalle { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 10.5px; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-top: 3px; +} +.tile-info-meta { font-size: 11.5px; color: var(--muted); margin-top: 4px; } +.tile-info-sub { + font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; + margin: 8px 0 6px; border-top: 1px solid var(--panel-border); padding-top: 8px; } -.viz-row { - display: grid; grid-template-columns: 22px 1fr 88px 52px 22px; - align-items: center; gap: 4px; padding: 3px 2px; border-radius: 4px; +/* Puces de couches : œil + nom + téléchargement, repliées sur 2 colonnes */ +.vpills { display: flex; flex-wrap: wrap; gap: 5px; } +.vpill { + display: inline-flex; align-items: center; gap: 3px; + padding: 2px 4px 2px 7px; border-radius: 999px; + background: rgba(255, 255, 255, 0.05); } -.viz-row:hover { background: rgba(255, 255, 255, 0.05); } -.viz-row .mini { - width: 20px; height: 20px; padding: 0; border: none; background: none; - display: flex; align-items: center; justify-content: center; - color: var(--muted); font-size: 13px; text-decoration: none; +.vpill.on { background: rgba(90, 169, 230, 0.14); } +.vpill-eye, .vpill-name { + border: none; background: none; padding: 0; cursor: pointer; color: var(--muted); } -.viz-row .mini:hover { color: var(--accent); } -.viz-row .mini svg { width: 14px; height: 14px; } -.viz-name { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.viz-date { font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; } -.viz-size { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } +.vpill-eye { + display: inline-flex; width: 16px; height: 16px; + align-items: center; justify-content: center; +} +.vpill-eye svg { width: 13px; height: 13px; } +.vpill.on .vpill-eye { color: var(--accent); } +.vpill-name { font-size: 11.5px; color: var(--text); white-space: nowrap; } +.vpill-name:hover { color: var(--accent); } +.vpill-dl { + color: var(--muted); text-decoration: none; font-size: 12px; + padding: 0 4px; border-radius: 4px; +} +.vpill-dl:hover { color: var(--accent); } -#infoActions { margin-top: 12px; display: flex; gap: 8px; } +.tile-info-actions { margin-top: 10px; } +.tile-info-actions button { width: 100%; font-size: 12px; padding: 6px 10px; } + +/* --- Outils carte (haut droite) --- */ +#mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; } +#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; } +#btnMenu { display: none; } /* --- Puces d'état (bas d'écran) --- */ .chip { @@ -1238,39 +1370,49 @@ input[type="range"]::-moz-range-thumb { background: var(--panel); border: 1px solid var(--panel-border); border-radius: 6px; padding: 5px 10px; font-size: 11px; color: var(--muted); - backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } -#zoomIndicator { bottom: 12px; left: 12px; font-variant-numeric: tabular-nums; } +#zoomIndicator { bottom: 12px; left: var(--left-ui); font-variant-numeric: tabular-nums; } #hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; } -/* --- Génération de zones --- */ +/* --- Onglet Génération --- */ +/* Actions Zone / Compléter : propres à l'onglet, pleine largeur */ +.gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.gen-actions button { width: 100%; font-size: 12.5px; } +#btnGenZone { background: var(--accent-dim); border-color: var(--accent-dim); } +#btnGenZone:hover { background: var(--accent); border-color: var(--accent); } +#btnComplete { + font-weight: 600; letter-spacing: 0.02em; + background: var(--success-dim); border-color: var(--success-dim); color: #eafff4; +} +#btnComplete:hover { background: var(--success); border-color: var(--success); } +#btnComplete .ok-ic { margin-right: 6px; font-weight: 700; } #genBar { - bottom: 48px; left: 50%; transform: translateX(-50%); - display: none; align-items: center; gap: 12px; - padding: 10px 14px; max-width: min(96vw, 900px); flex-wrap: wrap; + display: none; flex-direction: column; gap: 8px; + margin-top: 8px; padding: 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--panel-border); border-radius: 8px; } #genBar.show { display: flex; } -#genMsg { font-size: 12.5px; } +#genMsg { font-size: 12.5px; line-height: 1.45; } .gen-opt { - display: flex; align-items: center; gap: 6px; - font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + font-size: 12px; color: var(--text); cursor: pointer; } +.gen-opt.col { flex-direction: column; align-items: stretch; gap: 4px; } +.gen-opt.col > span { color: var(--muted); font-size: 11px; } +.gen-opt select { flex: 1; min-width: 0; } +.gen-run { display: flex; gap: 8px; margin-top: 2px; } +.gen-run #genGo { flex: 1; } -/* File de génération : panneau latéral — peut occuper toute la hauteur - de l'écran si besoin (plafonné au viewport, scroll interne au-delà) */ -#genQueue { - bottom: 48px; right: 12px; width: 780px; max-width: 94vw; - max-height: calc(100vh - 60px); - display: flex; flex-direction: column; -} +/* File de génération : section de l'onglet, largeur de la barre latérale */ +#genQueue { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; } +.queue-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 6px; } +#genQueueCount { margin-left: auto; font-size: 11px; color: var(--muted); } #genQueueBody { - padding: 8px 10px; flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } -.gen-queue-actions { display: flex; align-items: center; gap: 8px; } -.gen-queue-count { font-size: 11px; color: var(--muted); } .gen-task { border: 1px solid var(--panel-border); border-radius: 6px; } -.gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; } +.gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; flex-wrap: wrap; } .gen-task-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--muted); @@ -1282,7 +1424,7 @@ input[type="range"]::-moz-range-thumb { .gen-task.failed .gen-task-dot { background: var(--danger); } @keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } .gen-task-label { - font-size: 12px; flex: 1; min-width: 0; + font-size: 12px; flex: 1; min-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gen-task-state { font-size: 11px; white-space: nowrap; } @@ -1294,20 +1436,19 @@ input[type="range"]::-moz-range-thumb { } .gen-task-toggle { padding: 1px 6px; font-size: 11px; } .gen-task-log { - margin: 0 8px 8px; max-height: 160px; overflow: auto; + margin: 0 8px 8px; max-height: 140px; overflow: auto; background: #0d1017; border: 1px solid var(--panel-border); border-radius: 6px; padding: 8px 10px; font: 10.5px/1.45 ui-monospace, Menlo, monospace; color: #9fb3d9; white-space: pre-wrap; } -/* Tuiles une par une : nom court + puces d'étapes (types de visualisation). - Sans plafond : c'est le corps du panneau qui scrolle si trop long. */ +/* Tuiles une par une : nom court + puces d'étapes (types de visualisation) */ .gen-task-tiles { margin: 0 8px 8px; display: flex; flex-direction: column; gap: 4px; } .gen-tile { - display: flex; align-items: center; gap: 8px; + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border: 1px solid var(--panel-border); border-radius: 6px; padding: 4px 8px; font-size: 11.5px; } @@ -1334,12 +1475,28 @@ input[type="range"]::-moz-range-thumb { .gen-tile.done .gen-tile-state { color: #39d98a; } .gen-tile.failed .gen-tile-state { color: var(--danger); } +/* Barres de défilement fines et discrètes */ +.tab-page::-webkit-scrollbar, +.gen-task-log::-webkit-scrollbar { width: 8px; } +.tab-page::-webkit-scrollbar-thumb, +.gen-task-log::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.14); border-radius: 4px; +} +.tab-page::-webkit-scrollbar-track, +.gen-task-log::-webkit-scrollbar-track { background: transparent; } + body.drawing, body.drawing #viewport { cursor: crosshair !important; } -/* --- Responsive --- */ +/* --- Responsive : la barre latérale devient un tiroir --- */ @media (max-width: 720px) { - #layerPanel { width: min(280px, 62vw); } - #infoPanel { width: min(310px, 66vw); top: auto; bottom: 48px; } + body { --left-ui: 12px; } + #sidebar { + width: min(320px, 88vw); z-index: 1200; + transform: translateX(calc(-100% - 20px)); + transition: transform 0.25s ease; + } + body.menu-open #sidebar { transform: none; } + #btnMenu { display: inline-block; } #hint { display: none; } } """ @@ -1371,11 +1528,19 @@ const map = L.map('viewport', { minZoom: 4, maxZoom: 19 }); +// Pane dédié au dessin de zones : les tuiles LiDAR sont des marqueurs +// (pane 600) et les vecteurs par défaut vivent en dessous (pane 400) ; +// on place rectangle et cellules fantômes au-dessus (620), sans capter +// les clics (pointer-events: none → la carte reste manipulable). +map.createPane('genDraw'); +map.getPane('genDraw').style.zIndex = 620; +map.getPane('genDraw').style.pointerEvents = 'none'; window.map = map; // accès débogage / automatisations window.TILES = TILES; const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19 + maxZoom: 19, + keepBuffer: 1 }).addTo(map); L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map); @@ -1428,7 +1593,10 @@ function activeLayers() { } // ============================================================ -// Marqueurs : une div par tuile, une par couche active +// Marqueurs : une div par tuile, une par couche active. +// Chaque entrée met en cache div + par couche + projections : +// les parcours déclenchés par moveend/zoomend ne font ni querySelector +// ni lecture de layout (getBoundingClientRect). // ============================================================ let gridBounds = null; const markers = new Map(); @@ -1451,7 +1619,15 @@ function registerTile(t) { keyboard: false }).addTo(map); mk.on('click', () => { if (!genMode) showTileInfo(t); }); - markers.set(tileKey(t), mk); + const el = mk.getElement(); + markers.set(tileKey(t), { + mk: mk, t: t, + div: el ? el.querySelector('.ltile') : null, + imgs: {}, + latlngs: t.corners.map(c => L.latLng(c[0], c[1])), + bounds: L.latLngBounds(t.corners), + proj: null, projZoom: NaN + }); } function recomputeGridBounds() { @@ -1466,10 +1642,27 @@ function recomputeGridBounds() { for (const t of TILES) registerTile(t); recomputeGridBounds(); -function tileDiv(t) { - const mk = markers.get(tileKey(t)); - const el = mk && mk.getElement(); - return el ? el.querySelector('.ltile') : null; +function entryDiv(entry) { + if (!entry.div) { + const el = entry.mk.getElement(); + entry.div = el ? el.querySelector('.ltile') : null; + } + return entry.div; +} + +// Taille projetée (px) d'une tuile au zoom courant, mise en cache par zoom. +function tileSize(entry) { + const zoom = map.getZoom(); + if (entry.projZoom === zoom && entry.proj) return entry.proj; + const c = entry.latlngs; + const sw = map.project(c[0], zoom), se = map.project(c[1], zoom), + nw = map.project(c[3], zoom); + entry.proj = { + w: Math.hypot(se.x - sw.x, se.y - sw.y), + h: Math.hypot(nw.x - sw.x, nw.y - sw.y) + }; + entry.projZoom = zoom; + return entry.proj; } // Redimensionne + rote chaque tuile selon les coins projetés à l'échelle courante @@ -1479,10 +1672,12 @@ function layoutTiles() { // de 1 km ne fait que ~0,6 px et l'arrondi l'écrase à 0 (rotations fausses // de 0/45/90°, largeurs 0 ou 1 px, tuiles qui ne s'emboîtent plus). const zoom = map.getZoom(); - for (const t of TILES) { - const div = tileDiv(t); + for (const entry of markers.values()) { + const div = entryDiv(entry); if (!div) continue; - const [sw, se, ne, nw] = t.corners.map(c => map.project(L.latLng(c[0], c[1]), zoom)); + const c = entry.latlngs; + const sw = map.project(c[0], zoom), se = map.project(c[1], zoom), + ne = map.project(c[2], zoom), nw = map.project(c[3], zoom); const w = Math.hypot(se.x - sw.x, se.y - sw.y); const h = Math.hypot(nw.x - sw.x, nw.y - sw.y); const ang = Math.atan2(se.y - sw.y, se.x - sw.x) * 180 / Math.PI; @@ -1494,6 +1689,8 @@ function layoutTiles() { div.style.width = w + 'px'; div.style.height = h + 'px'; div.style.transform = 'translate(-50%, -50%) rotate(' + ang.toFixed(4) + 'deg)'; + entry.proj = { w: w, h: h }; + entry.projZoom = zoom; } // Resynchronise aussi les : au premier rendu les marqueurs viennent // tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés. @@ -1504,20 +1701,25 @@ function layoutTiles() { // applique opacité et ordre d'empilement (z-index). function syncTileLayers() { const act = activeLayers(); - for (const t of TILES) { - const div = tileDiv(t); + for (const entry of markers.values()) { + const div = entryDiv(entry); if (!div) continue; - div.querySelectorAll('img[data-layer]').forEach(img => { - if (!act.includes(img.dataset.layer)) { img.src = ''; img.remove(); } - }); + for (const k of Object.keys(entry.imgs)) { + if (act.includes(k)) continue; + entry.imgs[k].src = ''; + entry.imgs[k].remove(); + delete entry.imgs[k]; + } act.forEach((k, i) => { - if (!t.viz[k]) return; - let img = div.querySelector('img[data-layer="' + CSS.escape(k) + '"]'); + if (!entry.t.viz[k]) return; + let img = entry.imgs[k]; if (!img) { img = document.createElement('img'); img.dataset.layer = k; img.alt = ''; + img.decoding = 'async'; div.appendChild(img); + entry.imgs[k] = img; } img.style.zIndex = String(i + 1); img.style.opacity = String(state.layers.opacity[k]); @@ -1526,29 +1728,37 @@ function syncTileLayers() { updateImages(); } -// Bascule vignette (256 px) ↔ image pleine résolution selon le zoom : -// en vue d'ensemble la vignette suffit ; quand une tuile dépasse ~300 px à -// l'écran ET est visible dans le viewport (marge de préchargement), on charge -// l'image complète pour conserver les détails. En dézoomant on retombe sur -// la vignette pour libérer la mémoire. +// Bascule vignette (256 px) ↔ vignette intermédiaire (640 px) ↔ image +// pleine résolution selon le zoom : en vue d'ensemble la vignette suffit ; +// au-delà de ~900 px à l'écran on charge l'image complète pour conserver +// les détails, l'intermédiaire faisant la transition. En dézoomant on +// retombe sur la vignette pour libérer la mémoire. Visibilité et tailles +// sont calculées en coordonnées projetées : aucun reflow. +const PRELOAD_MARGIN_PX = 300; +const FULL_RES_PX = 900; + function updateImages() { - const vp = viewport.getBoundingClientRect(); - const marge = 300; - const act = new Set(activeLayers()); - for (const t of TILES) { - const div = tileDiv(t); - if (!div) continue; - const r = div.getBoundingClientRect(); - const visible = r.right > vp.left - marge && r.left < vp.right + marge && - r.bottom > vp.top - marge && r.top < vp.bottom + marge; - const big = visible && (r.width > 300 || r.height > 300); - for (const img of div.querySelectorAll('img[data-layer]')) { - const k = img.dataset.layer; - if (!act.has(k)) continue; - const v = t.viz[k]; + if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire + const zoom = map.getZoom(); + const b = map.getBounds(); + const swP = map.project(b.getSouthWest(), zoom); + const neP = map.project(b.getNorthEast(), zoom); + const visBounds = L.latLngBounds( + map.unproject(L.point(swP.x - PRELOAD_MARGIN_PX, swP.y + PRELOAD_MARGIN_PX), zoom), + map.unproject(L.point(neP.x + PRELOAD_MARGIN_PX, neP.y - PRELOAD_MARGIN_PX), zoom)); + for (const entry of markers.values()) { + const visible = visBounds.intersects(entry.bounds); + const p = tileSize(entry); + const big = visible && (p.w > 300 || p.h > 300); + for (const k in entry.imgs) { + const v = entry.t.viz[k]; if (!v) continue; // Les couches quasi transparentes n'ont pas besoin de pleine résolution - const want = (big && state.layers.opacity[k] > 0.15) ? v.full : v.thumb; + let want = v.thumb; + if (big && state.layers.opacity[k] > 0.15) { + want = (v.mid && Math.max(p.w, p.h) < FULL_RES_PX) ? v.mid : v.full; + } + const img = entry.imgs[k]; if (!img.src.endsWith(want)) img.src = want; } } @@ -1587,10 +1797,17 @@ function renderLayerPanel() { if (!meta) continue; const row = document.createElement('div'); row.className = 'layer-row' + (state.layers.on[key] ? ' on' : ''); - row.draggable = true; + // Le glisser-déposer ne part que de la poignée ⠿ : sinon le drag natif + // de la ligne avale le mousedown des sliders (opacité non manipulable + // à la souris) et les clics sur les boutons. + row.draggable = false; row.dataset.layer = key; + row.addEventListener('mousedown', e => { + row.draggable = !!(e.target.closest && e.target.closest('.layer-grip')); + }); row.title = meta.label; row.innerHTML = + '⠿' + '' + '' + meta.label + '' + @@ -1615,9 +1832,8 @@ function renderLayerPanel() { state.layers.opacity[key] = v; pct.textContent = range.value + '%'; // Applique directement sans reconstruire le DOM - for (const t of TILES) { - const div = tileDiv(t); - const img = div && div.querySelector('img[data-layer="' + CSS.escape(key) + '"]'); + for (const entry of markers.values()) { + const img = entry.imgs[key]; if (img) img.style.opacity = String(v); } updateImages(); @@ -1625,7 +1841,7 @@ function renderLayerPanel() { range.addEventListener('change', saveLayers); row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); }); - row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); }); + row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; }); row.addEventListener('dragover', e => e.preventDefault()); row.addEventListener('drop', e => { e.preventDefault(); @@ -1660,15 +1876,41 @@ document.getElementById('osmRow').querySelector('.layer-name') document.getElementById('osmRow').querySelector('.layer-name').title = 'Cliquer : fond de carte clair / assombri'; -const layerPanel = document.getElementById('layerPanel'); -document.getElementById('layerCollapse').addEventListener('click', () => { - layerPanel.classList.toggle('collapsed'); - try { localStorage.setItem('lidarLayersCollapsed', layerPanel.classList.contains('collapsed') ? '1' : ''); } catch (e) {} -}); +// Barre latérale : repli + onglets (Couches / Génération) +const sidebar = document.getElementById('sidebar'); +function setSidebarCollapsed(collapsed) { + sidebar.classList.toggle('collapsed', collapsed); + document.body.classList.toggle('sb-collapsed', collapsed); + try { localStorage.setItem('lidarSidebarCollapsed', collapsed ? '1' : ''); } catch (e) {} +} +document.getElementById('sidebarCollapse').addEventListener('click', () => + setSidebarCollapsed(!sidebar.classList.contains('collapsed'))); try { - if (localStorage.getItem('lidarLayersCollapsed') === '1') layerPanel.classList.add('collapsed'); + if (localStorage.getItem('lidarSidebarCollapsed') === '1') setSidebarCollapsed(true); } catch (e) {} +const tabButtons = document.querySelectorAll('.tabbar .tab'); +let activeTab = 'layers'; +function switchTab(name) { + activeTab = name; + for (const b of tabButtons) { + b.classList.toggle('active', b.dataset.tab === name); + } + document.querySelectorAll('.tab-page').forEach(p => { p.hidden = p.id !== 'tab-' + name; }); + // L'onglet Tuile est contextuel : sa sélection n'est pas mémorisée + if (name === 'layers' || name === 'gen') { + try { localStorage.setItem('lidarTab', name); } catch (e) {} + } +} +for (const b of tabButtons) b.addEventListener('click', () => switchTab(b.dataset.tab)); +try { + const savedTab = localStorage.getItem('lidarTab'); + if (savedTab === 'layers' || savedTab === 'gen') switchTab(savedTab); +} catch (e) {} + +document.getElementById('btnMenu').addEventListener('click', () => + document.body.classList.toggle('menu-open')); + // ============================================================ // Vue : zoom / pan / persistance (#zoom,lat,lng dans l'URL) // ============================================================ @@ -1711,20 +1953,34 @@ document.getElementById('zoomReset').addEventListener('click', () => { history.replaceState(null, '', location.pathname + location.search); if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); }); +// will-change pendant l'animation de zoom uniquement : le scaling passe sur +// le GPU, et les couches sont libérées juste après (mémoire vidéo). +let zoomGpuTimer = null; +map.on('zoomstart', () => { + clearTimeout(zoomGpuTimer); + document.body.classList.add('zooming'); +}); map.on('zoomend', () => { zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1); layoutTiles(); + zoomGpuTimer = setTimeout(() => document.body.classList.remove('zooming'), 1200); }); -map.on('moveend', () => { updateImages(); saveViewHash(); }); +// L'URL (#zoom,lat,lng) est mise à jour en débouncé : un replaceState à chaque +// moveend coûte plus cher qu'il n'y paraît. +let viewHashTimer = null; +function saveViewHashSoon() { + clearTimeout(viewHashTimer); + viewHashTimer = setTimeout(saveViewHash, 400); +} +window.addEventListener('pagehide', () => { clearTimeout(viewHashTimer); saveViewHash(); }); +map.on('moveend', () => { updateImages(); saveViewHashSoon(); }); map.on('click', hideTileInfo); map.whenReady(() => layoutTiles()); // ============================================================ -// Panneau d'infos tuile (métadonnées de génération) +// Fiche tuile (onglet Tuile) : métadonnées + couches de la tuile // ============================================================ -const infoPanel = document.getElementById('infoPanel'); -const infoTitle = document.getElementById('infoTitle'); -const infoBody = document.getElementById('infoBody'); +const tileBody = document.getElementById('tileBody'); let currentTile = null; function methodLabel(m) { @@ -1746,94 +2002,130 @@ function layerPos(key) { return i === -1 ? 999 : i; } -function showTileInfo(t) { - currentTile = t; - const meta = t.meta || {}; - const vizRows = Object.keys(t.viz) - .sort((a, b) => layerPos(a) - layerPos(b)) - .map(k => { - const vm = meta.viz && meta.viz[k]; - return '
' + - '' + - '' + - (VIZ_META[k] ? VIZ_META[k].label : k) + '' + - '' + (vm ? vm.date : '') + '' + - '' + (vm ? humanSize(vm.size) : '') + '' + - '⤓' + - '
'; - }).join(''); +function fmtShortDate(s) { + return (s && s.length >= 16) + ? s.slice(8, 10) + '/' + s.slice(5, 7) + ' ' + s.slice(11, 16) + : (s || ''); +} - infoTitle.textContent = 'Tuile ' + t.col + ',' + t.row; - infoBody.innerHTML = - '
' + - '
Dalle
' + t.name + '
' + - '
Coordonnées L93
' + t.col + ' km E · ' + t.row + ' km N
' + - '
Résolution
' + t.resolution + ' m/px
' + - '
Taille
' + (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km
' + - '
Classification sol
' + methodLabel(meta.method) + '
' + - '
Générée le
' + (meta.generated || '?') + '
' + - '
' + - '
Visualisations
' + - vizRows + - '
' + - '' + + '
' + + '
' + t.name + '
' + + '
' + + (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km · ' + methodLabel(meta.method) + + (meta.generated ? ' · généré le ' + fmtShortDate(meta.generated) : '') + + '
' + + '
Visualisations
' + + '
' + + '
' + + '' + '
'; - infoBody.querySelectorAll('button[data-act="show"]').forEach(btn => { + const pills = el.querySelector('.vpills'); + Object.keys(t.viz) + .sort((a, b) => layerPos(a) - layerPos(b)) + .forEach(k => { + const vm = meta.viz && meta.viz[k]; + const label = VIZ_META[k] ? VIZ_META[k].label : k; + const tip = vm ? [label, vm.date, vm.size != null ? humanSize(vm.size) : ''] + .filter(Boolean).join(' — ') : label; + const pill = document.createElement('span'); + pill.className = 'vpill' + (state.layers.on[k] ? ' on' : ''); + pill.title = tip; + pill.innerHTML = + '' + + '' + + '⤓'; + pills.appendChild(pill); + }); + + // Œil ou nom : bascule de la couche, reflet immédiat dans la puce + el.querySelectorAll('button[data-viz]').forEach(btn => { btn.addEventListener('click', () => { const k = btn.dataset.viz; if (!VIZ_META[k]) return; - state.layers.on[k] = true; - if (state.layers.opacity[k] === 0) state.layers.opacity[k] = 1; + state.layers.on[k] = !state.layers.on[k]; + if (state.layers.on[k] && state.layers.opacity[k] === 0) state.layers.opacity[k] = 1; saveLayers(); renderLayerPanel(); syncTileLayers(); - layerPanel.classList.remove('collapsed'); + const pill = btn.closest('.vpill'); + if (pill) { + pill.classList.toggle('on', state.layers.on[k]); + const eye = pill.querySelector('.vpill-eye'); + if (eye) eye.innerHTML = state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF; + } }); }); - const regen = document.getElementById('infoRegen'); + const regen = el.querySelector('.tile-info-regen'); if (regen) regen.addEventListener('click', () => regenerateCurrentTile()); + const closeBtn = el.querySelector('.tile-info-close'); + if (closeBtn) closeBtn.addEventListener('click', hideTileInfo); + return el; +} +function showTileInfo(t) { + currentTile = t; // Surlignage de la tuile sélectionnée - for (const tk of TILES) { - const div = tileDiv(tk); - if (div) div.classList.toggle('selected', tk === t); + for (const entry of markers.values()) { + const div = entryDiv(entry); + if (div) div.classList.toggle('selected', entry.t === t); } - infoPanel.hidden = false; + tileBody.replaceChildren(tileInfoContent(t)); + switchTab('tile'); } function hideTileInfo() { - infoPanel.hidden = true; currentTile = null; - for (const t of TILES) { - const div = tileDiv(t); + for (const entry of markers.values()) { + const div = entryDiv(entry); if (div) div.classList.remove('selected'); } + const empty = document.createElement('div'); + empty.className = 'tile-empty'; + empty.textContent = 'Cliquez sur une tuile de la carte pour afficher ses informations de génération.'; + tileBody.replaceChildren(empty); + if (activeTab === 'tile') switchTab('layers'); } -document.getElementById('infoClose').addEventListener('click', hideTileInfo); document.addEventListener('keydown', e => { if (e.key === 'Escape') hideTileInfo(); }); function regenerateCurrentTile() { - if (!currentTile || !apiAvailable) return; + if (!currentTile || !apiAvailable || !regenAllowed) return; const t = currentTile; hideTileInfo(); pendingTiles = [[t.col, t.row]]; pendingRegen = true; pendingCompleteAll = false; document.getElementById('genRegen').checked = true; - genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez'; genGo.style.display = ''; - genBar.classList.add('show'); + showGenBar('1 tuile à régénérer — choisissez les options puis lancez'); } // ============================================================ // Génération de nouvelles zones via l'API web (./run.sh --serve) // ============================================================ let apiAvailable = false; +let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR) const genBtn = document.getElementById('btnGenZone'); const btnComplete = document.getElementById('btnComplete'); const genBar = document.getElementById('genBar'); @@ -1846,6 +2138,7 @@ const genIgnClasses = document.getElementById('genIgnClasses'); const genViz = document.getElementById('genViz'); const genBareEarth = document.getElementById('genBareEarth'); const genQueue = document.getElementById('genQueue'); +const genBadge = document.getElementById('genBadge'); const genQueueBody = document.getElementById('genQueueBody'); const genQueueClear = document.getElementById('genQueueClear'); const genQueueCount = document.getElementById('genQueueCount'); @@ -1853,9 +2146,26 @@ let genMode = false, drawStart = null, drawRect = null; let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false; const ghostCells = []; +// Affiche la barre de confirmation de génération, dans l'onglet Génération +// (bascule automatique sur cet onglet). +function showGenBar(msg) { + if (msg !== undefined) genMsg.textContent = msg; + switchTab('gen'); + genBar.classList.add('show'); +} + fetch('/api/status').then(r => { apiAvailable = r.ok; - if (r.ok) { genBtn.hidden = false; btnComplete.hidden = false; } + return r.ok ? r.json() : null; +}).then(d => { + if (!d) { if (apiAvailable) { genBtn.hidden = false; btnComplete.hidden = false; } return; } + // Génération réservée au réseau local (LIDAR_REGEN_CIDR, cf. webapp.py) : + // les boutons de traitement restent masqués pour les autres clients. + regenAllowed = d.regen_allowed !== false; + if (apiAvailable) { + genBtn.hidden = !regenAllowed; + btnComplete.hidden = !regenAllowed; + } }).catch(() => {}); function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; } @@ -1882,9 +2192,8 @@ genBtn.addEventListener('click', () => { genMode = true; map.dragging.disable(); document.body.classList.add('drawing'); - genMsg.textContent = 'Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)'; genGo.style.display = 'none'; - genBar.classList.add('show'); + showGenBar('Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)'); }); map.on('mousedown', e => { @@ -1892,7 +2201,7 @@ map.on('mousedown', e => { drawStart = e.latlng; if (drawRect) drawRect.remove(); drawRect = L.rectangle([drawStart, drawStart], - { color: '#4cc9f0', weight: 2, dashArray: '6 4', fillOpacity: 0.05 }).addTo(map); + { color: '#4cc9f0', weight: 2, dashArray: '6 4', fillOpacity: 0.05, pane: 'genDraw' }).addTo(map); }); map.on('mousemove', e => { if (!genMode || !drawStart || !drawRect) return; @@ -1904,9 +2213,8 @@ map.on('mouseup', e => { drawStart = null; if (Math.abs(b.getWest() - b.getEast()) < 1e-9 || Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return; exitGenMode(); - genMsg.textContent = 'Analyse de la zone...'; genGo.style.display = 'none'; - genBar.classList.add('show'); + showGenBar('Analyse de la zone...'); fetch('/api/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], @@ -1917,7 +2225,7 @@ map.on('mouseup', e => { pendingTiles = (d.cells || []).map(c => [c.col, c.row]); pendingCompleteAll = false; // zone dessinée : le scope global est levé (d.cells || []).forEach(c => ghostCells.push( - L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map) + L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map) )); if (!pendingTiles.length) { genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' + @@ -1929,7 +2237,7 @@ map.on('mouseup', e => { (genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') + (d.capped ? ' (zone tronquée à 400)' : ''); genGo.style.display = ''; - }).catch(() => { genMsg.textContent = 'API indisponible'; }); + }).catch(() => { showGenBar('API indisponible'); }); }); // « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au @@ -1938,9 +2246,8 @@ map.on('mouseup', e => { btnComplete.addEventListener('click', () => { if (!apiAvailable) return; clearGhosts(); - genBar.classList.add('show'); - genMsg.textContent = 'Analyse des tuiles présentes dans input/...'; genGo.style.display = 'none'; + showGenBar('Analyse des tuiles présentes dans input/...'); fetch('/api/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ all_missing: true, regenerate: genRegen.checked, @@ -1949,7 +2256,7 @@ btnComplete.addEventListener('click', () => { pendingTiles = (d.cells || []).map(c => [c.col, c.row]); pendingCompleteAll = true; (d.cells || []).forEach(c => ghostCells.push( - L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map) + L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map) )); if (!pendingTiles.length) { genMsg.textContent = 'Aucune visualisation manquante pour les couches choisies'; @@ -1959,7 +2266,7 @@ btnComplete.addEventListener('click', () => { genMsg.textContent = pendingTiles.length + ' tuile(s) à compléter (dalles présentes dans input/)' + (d.capped ? ' (liste tronquée)' : ''); genGo.style.display = ''; - }).catch(() => { genMsg.textContent = 'API indisponible'; }); + }).catch(() => { showGenBar('API indisponible'); }); }); genGo.addEventListener('click', () => { @@ -1984,7 +2291,7 @@ genGo.addEventListener('click', () => { pendingCompleteAll = false; return startGenTask(nTiles + ' tuile(s) — ' + verb); }) - .catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); }); + .catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); }); }); genCancel.addEventListener('click', () => { @@ -2089,10 +2396,17 @@ function genTaskEl(t) { } function renderGenQueue() { + // Ne reconstruit le DOM que si les tâches ont changé : le sondage de 2 s + // ne doit pas faire vibrer la liste ni consommer du CPU pour rien. + const sig = JSON.stringify(genTasks); + if (sig === renderGenQueue.sig) { genQueue.hidden = genTasks.length === 0; return; } + renderGenQueue.sig = sig; genQueueBody.textContent = ''; // Plus récentes en haut for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t)); const running = genTasks.filter(t => t.state === 'running').length; + genBadge.hidden = running === 0; + genBadge.textContent = running > 0 ? String(running) : ''; genQueueCount.textContent = genTasks.length ? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)') : ''; @@ -2135,19 +2449,20 @@ function mergeTilesData(data) { for (const t of data.tiles) { if (t.corners) byKey.set(tileKey(t), t); } + const curByKey = new Map(TILES.map(t => [tileKey(t), t])); let changed = false; // Retire les tuiles disparues (ex. dalle 0,5 m remplacée par ses // sous-tuiles 0,2 m plus fines) for (let i = TILES.length - 1; i >= 0; i--) { const key = tileKey(TILES[i]); if (byKey.has(key)) continue; - const mk = markers.get(key); - if (mk) { mk.remove(); markers.delete(key); } + const entry = markers.get(key); + if (entry) { entry.mk.remove(); markers.delete(key); } TILES.splice(i, 1); changed = true; } byKey.forEach((t, key) => { - const cur = TILES.find(x => tileKey(x) === key); + const cur = curByKey.get(key); if (!cur) { TILES.push(t); registerTile(t); @@ -2198,6 +2513,7 @@ function pollLiveTiles() { function pollGenStatus() { if (genPollTimer) return; // un seul sondage global pour toute la file genPollTimer = setInterval(() => { + if (document.hidden) return; // onglet en arrière-plan : reprend au retour fetch('/api/status').then(r => r.json()).then(d => { // Réponse d'erreur (ex: machine de traitement injoignable) : ignorer, // le sondage reprendra — ne pas marquer les tâches en échec. @@ -2287,8 +2603,8 @@ else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => { // ============================================================ renderOsmRow(); renderLayerPanel(); -syncTileLayers(); fitView(); +syncTileLayers(); layoutTiles(); zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1); @@ -2311,7 +2627,7 @@ function checkNewLayers() { }).catch(() => {}); } checkNewLayers(); -setInterval(checkNewLayers, 60000); +setInterval(() => { if (!document.hidden) checkNewLayers(); }, 60000); if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => { layerRefreshBtn.textContent = '…'; layerRefreshBtn.disabled = true; diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 729381b..799bcba 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -257,14 +257,29 @@ def test_build_index_generates_html(tmp_path): assert "t.started === s.started" in app_js # Bouton « Compléter » : visualisations manquantes des dalles présentes # dans input/ (scope all_missing envoyé au preview et au generate). + # « + Zone » et « Compléter » ne vivent que dans l'onglet Génération, + # jamais dans la barre d'outils flottante de la carte. assert 'id="btnComplete"' in content + map_tools = content.split('id="mapTools"', 1)[1].split('', 1)[0] + assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools + tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0] + assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen assert "all_missing" in app_js and app_js.count("all_missing") >= 2 + # Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs + # Leaflet, pane 600) pour rectangle et cellules fantômes. + assert "createPane('genDraw')" in app_js + assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules assert 'assets/app.css' in content # Bouton d'actualisation des couches (détection des nouveautés sur disque) : # le ↻ resynchronise puis reconstruit la carte (mode webapp distante). assert 'id="layerRefresh"' in content assert "/api/layers" in app_js and "/api/sync" in app_js assert 'assets/app.js' in content + # Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN + assert 'assets/vendor/leaflet/leaflet.js' in content + assert 'assets/vendor/leaflet/leaflet.css' in content + assert 'unpkg.com' not in content + assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file() # Fin d'un run de génération : synchronisation puis rechargement (le # rechargement direct reste en secours si /api/sync est absent). assert "syncThenReload" in app_js @@ -284,6 +299,23 @@ def test_build_index_generates_html(tmp_path): assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile +def test_mid_thumbnails_generated(tmp_path): + """Vignette intermédiaire 640 px générée et référencée dans l'index.""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',)) + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + viz = data['tiles'][0]['viz']['hillshade_multi'] + assert 'mid' in viz + assert (output_dir / viz['mid']).exists() + assert viz['mid'].endswith("_mid.jpg") + + def test_subtiled_viz_covers_generation_choices(): """Les viz générables depuis la carte sont sous-tuilées (affichage fluide).""" from lidar_pipeline.index import _CARTO_SUBTILED_VIZ @@ -387,6 +419,8 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path): hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif" aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif" assert hill_avif.exists() and aspect_avif.exists() + hill_mid = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0_mid.webp" + assert hill_mid.exists() m_hill_1 = hill_avif.stat().st_mtime m_aspect_1 = aspect_avif.stat().st_mtime diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py index 3c549af..35382b6 100644 --- a/lidar_pipeline/tests/test_webapp.py +++ b/lidar_pipeline/tests/test_webapp.py @@ -583,3 +583,81 @@ def test_require_token(): # Sans jeton configuré : tout passe (LAN de confiance) webapp.API_TOKEN = None assert webapp._require_token(None) is None + + +class _FakeRequest: + """Requête minimale pour tester la restriction par IP (peer + XFF).""" + + def __init__(self, host=None, xff=None): + self.client = type("C", (), {"host": host})() if host else None + self.headers = {"x-forwarded-for": xff} if xff else {} + + +def test_generate_restricted_to_lan_cidr(): + """/api/generate n'est autorisé que depuis LIDAR_REGEN_CIDR (défaut 192.168.1.0/24).""" + from fastapi import HTTPException + import lidar_pipeline.webapp as webapp + old = webapp.REGEN_CIDR + webapp.REGEN_CIDR = "192.168.1.0/24" + try: + # IP du réseau autorisé + webapp._require_lan_for_generation(_FakeRequest(host="192.168.1.42")) + # Reverse proxy local (pair dans le réseau) : X-Forwarded-For désigne + # le client réel derrière lui — refusé s'il est hors réseau + webapp._require_lan_for_generation( + _FakeRequest(host="192.168.1.50", xff="192.168.1.10, 192.168.1.50")) + for host, xff in (("10.0.0.5", None), ("8.8.8.8", None), + ("192.168.3.1", None), ("2001:db8::1", None), + # XFF forgé par un client externe : ignoré (pair hors réseau) + ("8.8.8.8", "192.168.1.5"), + # Client réel externe derrière le proxy local + ("192.168.1.50", "8.8.8.8"), + # IP absente + (None, None)): + try: + webapp._require_lan_for_generation(_FakeRequest(host=host, xff=xff)) + assert False, f"403 attendu pour host={host!r} xff={xff!r}" + except HTTPException as e: + assert e.status_code == 403 + assert "réseau local" in e.detail + finally: + webapp.REGEN_CIDR = old + + +def test_generate_cidr_disabled(monkeypatch): + """LIDAR_REGEN_CIDR vide = restriction levée (tout client autorisé).""" + import lidar_pipeline.webapp as webapp + monkeypatch.setattr(webapp, "REGEN_CIDR", "") + webapp._require_lan_for_generation(_FakeRequest(host="8.8.8.8")) + webapp._require_lan_for_generation(_FakeRequest()) + + +def test_status_exposes_regen_allowed(): + """/api/status indique à l'interface si le client peut générer.""" + import lidar_pipeline.webapp as webapp + assert webapp.status(_FakeRequest(host="192.168.1.7"))["regen_allowed"] is True + assert webapp.status(_FakeRequest(host="8.8.8.8"))["regen_allowed"] is False + + +def test_auto_sync_once_skips_when_busy(monkeypatch): + """Le cycle de cache ne lance rien si un rebuild tourne déjà.""" + import time as _time + import lidar_pipeline.webapp as webapp + import lidar_pipeline.index as index_mod + calls = [] + monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out)) + monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0") + # Un cycle démarre bien (sync + rebuild) + assert webapp._auto_sync_once() is True + for _ in range(200): + if not webapp.rebuild_status()["running"]: + break + _time.sleep(0.05) + assert calls + # Rebuild artificiellement occupé : le cycle suivant est sauté sans erreur + webapp._rebuild["running"] = True + try: + assert webapp._auto_sync_once() is False + assert len(calls) == 1 + finally: + webapp._rebuild["running"] = False diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index 1f7f6a9..120e605 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -41,6 +41,7 @@ carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN """ import json +import logging import math import os import subprocess @@ -51,7 +52,7 @@ from pathlib import Path from typing import Optional -from fastapi import Depends, FastAPI, Header, HTTPException +from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel, Field @@ -73,6 +74,17 @@ API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None # Commande shell qui ramène les tuiles traitées dans output/ local (rsync # depuis la machine de traitement). Exécutée par /api/sync avant le rebuild. SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None +# Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) : +# CIDR en notation standard, vide = restriction levée. L'IP du client est +# l'IP de connexion (conservée par le DNAT Docker) ; si elle est dans le +# réseau autorisé, X-Forwarded-For est honoré (reverse proxy local qui +# expose la carte au-dehors — un client distant ne peut pas le forger +# puisque sa connexion directe est déjà hors réseau). +REGEN_CIDR = (os.environ.get("LIDAR_REGEN_CIDR", "192.168.1.0/24") or "").strip() +# Cache local périodique : relance la synchronisation + le rebuild toutes +# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient +# sa copie des tuiles de la machine de traitement. +AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0) # Résolutions générées par /api/generate (cf. _build_command). La détection des # tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète @@ -81,6 +93,27 @@ GENERATE_RESOLUTIONS = (0.5, 0.2) app = FastAPI(title="Carte LiDAR — génération de zones") +# Visibilité de la configuration au démarrage (docker compose logs) : le +# backend de génération est TOUJOURS défini par variable d'environnement. +logger = logging.getLogger("lidar") +if not logger.handlers: + # Hors cli.py (uvicorn seul), le logger 'lidar' n'a aucun handler — en + # ajouter un pour que la config soit visible dans les logs du conteneur. + _h = logging.StreamHandler(sys.stdout) + _h.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(_h) + logger.setLevel(logging.INFO) +if GENERATION_URL: + logger.info(f"Backend de génération des tuiles : {GENERATION_URL} " + f"(LIDAR_GENERATION_URL)") +else: + logger.info("Backend de génération des tuiles : local — consultation " + "autonome du cache, sans LIDAR_GENERATION_URL") +if SYNC_CMD: + logger.info(f"Cache local des tuiles : LIDAR_SYNC_CMD" + + (f" (rafraîchi toutes les {AUTO_SYNC_SECONDS:g}s)" + if AUTO_SYNC_SECONDS > 0 else " (à la demande)")) + # assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur # statique soit actif même avant la première génération de l'index. _assets_dir = OUTPUT_DIR / "assets" @@ -116,6 +149,51 @@ def _require_token(x_lidar_token: str = Header(None)): raise HTTPException(401, "token d'API manquant ou invalide") +def _ip_in_regen_cidr(ip): + """Vrai si l'IP appartient au CIDR autorisé (LIDAR_REGEN_CIDR).""" + if not REGEN_CIDR: + return True # restriction désactivée + if not ip: + return False + import ipaddress + try: + return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False) + except ValueError: + return False + + +def _client_ip(request): + """IP d'origine du client pour la restriction de génération. + + Le pair direct doit être dans le réseau autorisé ; dans ce cas seulement, + X-Forwarded-For (reverse proxy local) désigne le client réel derrière lui. + Un client externe connecté en direct est refusé sur son IP de connexion, + quel que soit le X-Forwarded-For qu'il annonce. + """ + if request is None: + return None + peer = getattr(request, "client", None) + peer_ip = getattr(peer, "host", None) if peer else None + if not _ip_in_regen_cidr(peer_ip): + return peer_ip # hors réseau (ou inconnu) : jugé sur cette IP + xff = request.headers.get("x-forwarded-for") if hasattr(request, "headers") else None + if xff: + first = xff.split(",")[0].strip() + if first: + return first + return peer_ip + + +def _require_lan_for_generation(request: Request): + """Réserve /api/generate aux clients du réseau local (LIDAR_REGEN_CIDR).""" + ip = _client_ip(request) + if not _ip_in_regen_cidr(ip): + raise HTTPException( + 403, f"génération de tuiles réservée au réseau local" + f"{f' ({REGEN_CIDR})' if REGEN_CIDR else ''} — " + f"client : {ip or 'IP inconnue'}") + + def _proxy_api(method, path, payload=None, timeout=30): """Transmet un appel d'API à la machine de traitement (mode webapp légère). @@ -365,11 +443,15 @@ def root(): @app.get("/api/status") -def status(): +def status(request: Request = None): + # Droit de lancer une génération depuis cette IP (guide l'interface) + regen_allowed = _ip_in_regen_cidr(_client_ip(request)) if GENERATION_URL: - # Webapp légère : l'état vient de la machine de traitement + # Webapp légère : l'état vient de la machine de traitement ; le droit + # de génération reste jugé sur l'IP du navigateur (localement). data = _proxy_api("GET", "/api/status", timeout=10) data["distant"] = True + data["regen_allowed"] = regen_allowed return data from .progress import progress_snapshot with _job_lock: @@ -381,6 +463,8 @@ def status(): "returncode": _job["returncode"], "cmd": _job["cmd"], "log": _tail_log(40), + # L'interface masque les boutons de génération hors réseau local + "regen_allowed": regen_allowed, # Tuiles une par une (nom court, type, état) — agrégées depuis les # événements écrits par le pipeline (.generation.events.jsonl) "tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()), @@ -475,6 +559,36 @@ def _start_rebuild(with_sync): return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)} +def _auto_sync_once(): + """Un cycle de cache local : synchronisation + rebuild, si rien ne tourne. + + Retourne True si un cycle a été lancé, False si un rebuild était déjà en + cours (le prochain cycle rattrapera). + """ + if _rebuild["running"]: + return False + try: + _start_rebuild(with_sync=True) + return True + except HTTPException: + return False # course perdue contre un lancement manuel : tant pis + + +def _auto_sync_loop(): + """Boucle du cache local : synchronise les tuiles périodiquement.""" + while True: + time.sleep(AUTO_SYNC_SECONDS) + _auto_sync_once() + + +# Conteneur webapp seul (cache local des tuiles) : lancer la boucle de +# synchronisation en arrière-plan si demandé. Sans LIDAR_SYNC_CMD il n'y a +# rien à synchroniser — pas de boucle (juste le rebuild à la demande). +if AUTO_SYNC_SECONDS > 0 and SYNC_CMD: + threading.Thread(target=_auto_sync_loop, daemon=True, + name="cache-tuiles").start() + + @app.post("/api/sync", dependencies=[Depends(_require_token)]) def sync_and_rebuild(): """Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan. @@ -578,7 +692,8 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False return cmd -@app.post("/api/generate", dependencies=[Depends(_require_token)]) +@app.post("/api/generate", dependencies=[Depends(_require_token), + Depends(_require_lan_for_generation)]) def generate(req: GenerateRequest): if GENERATION_URL: # Webapp légère : téléchargement + traitement sur la machine distante, diff --git a/run.sh b/run.sh index d01b1c3..03928b4 100755 --- a/run.sh +++ b/run.sh @@ -21,6 +21,11 @@ # --skip VIZ... Exclure ces visualisations # --file NOM... Traiter un ou plusieurs fichiers LAZ # --test Exécuter les tests unitaires +# --serve [PORT] Carte + API de génération (image complète, défaut: 8973) +# --serve-webapp [PORT] +# Conteneur webapp seul (image légère) : carte servie depuis +# le cache local de tuiles, génération déléguée à la machine +# de traitement (LIDAR_GENERATION_URL, LIDAR_SYNC_CMD) # -h Afficher l'aide complète set -e @@ -101,6 +106,9 @@ NO_INDEX_FLAG="" FETCH_TILES_ARGS="" SERVE_FLAG=0 SERVE_PORT=8973 +WEBAPP_FLAG=0 +WEBAPP_SERVE_PORT=8973 +WEBAPP_IMAGE="lidar-webapp" # Parse arguments manually (more robust than getopts for mixed short/long options) while [ $# -gt 0 ]; do @@ -142,6 +150,10 @@ while [ $# -gt 0 ]; do SERVE_FLAG=1; shift if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then SERVE_PORT="$1"; shift; fi ;; + --serve-webapp) + WEBAPP_FLAG=1; shift + if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then WEBAPP_SERVE_PORT="$1"; shift; fi + ;; -h|--help|-help) echo "Pipeline LiDAR Archéologique" echo "" @@ -171,6 +183,11 @@ while [ $# -gt 0 ]; do echo " --fetch-tiles COL,ROW..." echo " Télécharger ces dalles depuis l'IGN puis les traiter" echo " --serve [PORT] Servir la carte + API de génération de zones (défaut: 8973)" + echo " --serve-webapp [PORT]" + echo " Conteneur webapp seul (image légère, sans PDAL/GPU) :" + echo " carte servie depuis le cache local de tuiles, génération" + echo " déléguée via LIDAR_GENERATION_URL + LIDAR_SYNC_CMD" + echo " (cf. docs/DEPLOY_WEBAPP.md)" echo " --test Exécuter les tests unitaires" echo " -h Afficher cette aide" echo "" @@ -224,6 +241,16 @@ build_image() { fi } +# Image webapp légère (conteneur webapp seul — cf. Dockerfile.webapp) +build_webapp_image() { + echo "Mise à jour de l'image Docker webapp (cache)..." + if ! docker build -f Dockerfile.webapp -t "$WEBAPP_IMAGE" "$SCRIPT_DIR" >/tmp/lidar_webapp_build.log 2>&1; then + cat /tmp/lidar_webapp_build.log >&2 + echo "Échec du build de l'image webapp (journal : /tmp/lidar_webapp_build.log)" >&2 + exit 1 + fi +} + # Check for --test flag first if [ "$TEST_FLAG" -eq 1 ]; then build_image @@ -255,17 +282,67 @@ if [ "$SERVE_FLAG" -eq 1 ]; then if [ -n "$GPU_ARG" ] && [ "$GPU_ARG" != "all" ]; then GPU_ENV_FLAG="-e LIDAR_GPU=1 -e CUDA_VISIBLE_DEVICES=${GPU_ARG} --gpus '""device=${GPU_ARG}""'" fi + # Réseau autorisé à lancer les générations (/api/generate) : la valeur + # vient de l'environnement hôte LIDAR_REGEN_CIDR (défaut 192.168.1.0/24, + # chaîne vide pour lever la restriction). + REGEN_ENV_FLAG="-e LIDAR_REGEN_CIDR=${LIDAR_REGEN_CIDR-192.168.1.0/24}" exec docker run --rm --init $GPU_FLAG $CUDA_ENV_FLAG \ --user 1000:1000 \ -p "${SERVE_PORT}:8973" \ -v "${INPUT_DIR}:/data/input" \ -v "${OUTPUT_DIR}:/data/output" \ -e LIDAR_INPUT_DIR=/data/input -e LIDAR_OUTPUT_DIR=/data/output \ - $GPU_ENV_FLAG \ + $GPU_ENV_FLAG $REGEN_ENV_FLAG \ "$IMAGE_NAME" \ python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 fi +# Mode --serve-webapp : conteneur webapp SEUL (image légère, sans PDAL/GPU). +# La carte est servie depuis le cache local de tuiles (output/) ; la +# génération est déléguée à la machine de traitement et les tuiles reviennent +# par LIDAR_SYNC_CMD (rsync), rafraîchies aussi par LIDAR_AUTO_SYNC_SECONDS. +# Variables passées telles quelles depuis l'environnement hôte : +# LIDAR_GENERATION_URL machine de traitement (ex: http://192.168.1.50:8973) +# LIDAR_REMOTE_TOKEN jeton si la machine exige LIDAR_API_TOKEN +# LIDAR_SYNC_CMD rsync qui remplit le cache local de tuiles +# LIDAR_AUTO_SYNC_SECONDS resynchronisation périodique (0 = off) +# LIDAR_REGEN_CIDR réseau autorisé à lancer les générations +# (défaut 192.168.1.0/24, vide = restriction levée) +if [ "$WEBAPP_FLAG" -eq 1 ]; then + build_webapp_image + mkdir -p "$OUTPUT_DIR" + AUTO_SYNC_INFO="${LIDAR_AUTO_SYNC_SECONDS:-0}" + echo "============================================" + echo " Carte LiDAR — webapp seule (image légère)" + echo "============================================" + echo " http://127.0.0.1:${WEBAPP_SERVE_PORT}/" + echo " Génération distante : ${LIDAR_GENERATION_URL:-non configurée (LIDAR_GENERATION_URL)}" + if [ -n "$LIDAR_SYNC_CMD" ]; then + echo " Cache local tuiles : LIDAR_SYNC_CMD$([ "${LIDAR_AUTO_SYNC_SECONDS:-0}" != "0" ] && echo " (auto toutes les ${AUTO_SYNC_INFO}s)")" + else + echo " Cache local tuiles : désactivé (définir LIDAR_SYNC_CMD pour rsync)" + fi + echo "============================================" + WEBAPP_ENV=() + [ -n "$LIDAR_GENERATION_URL" ] && WEBAPP_ENV+=(-e LIDAR_GENERATION_URL="$LIDAR_GENERATION_URL") + [ -n "$LIDAR_REMOTE_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN") + [ -n "$LIDAR_API_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_API_TOKEN="$LIDAR_API_TOKEN") + [ -n "$LIDAR_SYNC_CMD" ] && WEBAPP_ENV+=(-e LIDAR_SYNC_CMD="$LIDAR_SYNC_CMD") + [ -n "${LIDAR_AUTO_SYNC_SECONDS:-}" ] && WEBAPP_ENV+=(-e LIDAR_AUTO_SYNC_SECONDS="$LIDAR_AUTO_SYNC_SECONDS") + [ -n "${LIDAR_REGEN_CIDR:-}" ] && WEBAPP_ENV+=(-e LIDAR_REGEN_CIDR="$LIDAR_REGEN_CIDR") + # Clé SSH du rsync (LIDAR_SYNC_CMD) : montée en lecture seule si présente + WEBAPP_SSH=() + [ -d "${HOME}/.ssh" ] && WEBAPP_SSH+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro") + exec docker run --rm --init \ + --user 1000:1000 \ + -p "${WEBAPP_SERVE_PORT}:8973" \ + -v "${OUTPUT_DIR}:/data/output" \ + "${WEBAPP_SSH[@]}" \ + -e LIDAR_OUTPUT_DIR=/data/output \ + "${WEBAPP_ENV[@]}" \ + "$WEBAPP_IMAGE" +fi + # Lancer le pipeline echo "============================================" echo " Pipeline LiDAR Archéologique" diff --git a/setup.py b/setup.py index 0bec772..b5633fe 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,11 @@ setup( version='2.0.0', description='Pipeline LiDAR pour détection archéologique', packages=find_packages(), + package_data={'lidar_pipeline': [ + 'assets/vendor/*', + 'assets/vendor/leaflet/*', + 'assets/vendor/leaflet/images/*', + ]}, python_requires='>=3.8', entry_points={ 'console_scripts': [