Centrer la carte sur le GPS du téléphone (HTTPS terminé par le proxy)

Le bouton ⌖ recentre la carte sur la position GPS du téléphone. L'API
Geolocation exige un contexte sécurisé : la webapp sert désormais du HTTP
et le TLS est porté par le reverse proxy (Traefik, labels dans l'override
local) ; le webapp est transparent au proxy (URLs relatives, X-Forwarded-For
pour la restriction LIDAR_REGEN_CIDR). Ajoute le modèle d'override
docker-compose.webapp.override.yml.example, un rebuild d'index à la demande
et met à jour doc/déploiement ; les tuiles sont servies à la demande.
This commit is contained in:
Antoine Jacquin
2026-09-13 19:57:09 +02:00
parent dd1aca3cfe
commit e2533bac48
16 changed files with 407 additions and 279 deletions

View File

@ -1332,9 +1332,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
<button id="btnGps" title="Centrer la carte sur la position GPS du téléphone">⌖</button>
</nav>
<div id="zoomIndicator" class="chip">Zoom</div>
<div id="gpsToast" class="chip" hidden></div>
<div id="hint" class="chip">
Molette : zoom · Clic-glisser : déplacer<br>
Clic sur tuile : infos de génération
@ -1618,6 +1620,7 @@ body.zooming .ltile { will-change: transform; }
#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; }
#btnGps.busy { color: var(--accent); border-color: var(--accent); }
/* --- Puces d'état (bas d'écran) --- */
.chip {
@ -1627,6 +1630,10 @@ body.zooming .ltile { will-change: transform; }
font-size: 11px; color: var(--muted);
}
#zoomIndicator { bottom: 12px; left: var(--left-ui); font-variant-numeric: tabular-nums; }
#gpsToast {
top: 54px; left: 50%; transform: translateX(-50%);
max-width: 80vw; text-align: center; line-height: 1.5;
}
#hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; }
/* --- Onglet Génération --- */
@ -2351,6 +2358,53 @@ document.getElementById('zoomReset').addEventListener('click', () => {
history.replaceState(null, '', location.pathname + location.search);
if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] });
});
// ============================================================
// Position GPS du téléphone : centrer la carte dessus
// ============================================================
const gpsBtn = document.getElementById('btnGps');
const gpsToast = document.getElementById('gpsToast');
let gpsToastTimer = null;
let gpsMarker = null;
function gpsMsg(text, ms) {
gpsToast.textContent = text;
gpsToast.hidden = false;
clearTimeout(gpsToastTimer);
gpsToastTimer = setTimeout(() => { gpsToast.hidden = true; }, ms || 4000);
}
gpsBtn.addEventListener('click', () => {
if (!('geolocation' in navigator)) {
gpsMsg('Géolocalisation non prise en charge par ce navigateur ' +
'(connexion sécurisée HTTPS requise).', 6000);
return;
}
gpsBtn.classList.add('busy');
try {
navigator.geolocation.getCurrentPosition(pos => {
gpsBtn.classList.remove('busy');
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, Math.max(map.getZoom(), 16));
if (gpsMarker) gpsMarker.remove();
gpsMarker = L.circleMarker(ll, {
radius: 5, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9
}).addTo(map);
if (pos.coords.accuracy) {
gpsMarker.bindTooltip('Précision GPS ~' + Math.round(pos.coords.accuracy) + ' m');
}
gpsMsg('Carte centrée sur votre position GPS.');
}, err => {
gpsBtn.classList.remove('busy');
let why = 'Aucune position reçue';
if (err && err.code === 1) why = 'Autorisation de localisation refusée';
else if (err && err.code === 2) why = 'Position momentanément indisponible';
else if (err && err.code === 3) why = 'Délai de localisation dépassé';
gpsMsg(why + '.', 6000);
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
} catch (e) {
gpsBtn.classList.remove('busy');
gpsMsg('La géolocalisation exige une connexion sécurisée (HTTPS).', 6000);
}
});
// 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;

View File

@ -686,63 +686,25 @@ def test_viz_step_labels_without_pipeline():
assert labels["pos_open"] == "Openness positive"
def test_sync_runs_command_then_rebuild(tmp_path, monkeypatch):
"""POST /api/sync exécute LIDAR_SYNC_CMD puis build_index en arrière-plan."""
def test_sync_is_rebuild_only(tmp_path, monkeypatch):
"""/api/sync régénère l'index en arrière-plan (plus de rsync)."""
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")
webapp._rebuild["done"] = None # ignore l'état d'un test précédent
assert webapp.sync_and_rebuild()["sync"] is True
assert webapp.sync_and_rebuild()["demarré"] is True
for _ in range(200):
s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]:
break
_time.sleep(0.05)
assert calls # l'index a été reconstruit après la synchronisation
assert calls # l'index a été reconstruit
assert webapp.rebuild_status()["error"] is None
assert webapp.rebuild_status()["done"] is not None
def test_sync_failure_reported_but_index_rebuilt(tmp_path, monkeypatch):
"""Un rsync en échec est remonté dans l'état sans bloquer le rebuild."""
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 3")
webapp._rebuild["done"] = None
webapp.sync_and_rebuild()
for _ in range(200):
s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]:
break
_time.sleep(0.05)
assert calls # rebuild quand même (données locales éventuelles)
assert "rc=3" in webapp.rebuild_status()["error"]
def test_sync_without_cmd_is_rebuild_only(monkeypatch):
"""Sans LIDAR_SYNC_CMD (machine locale), /api/sync = simple rebuild."""
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", None)
webapp._rebuild["done"] = None
assert webapp.sync_and_rebuild()["sync"] is False
for _ in range(200):
s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]:
break
_time.sleep(0.05)
assert calls and webapp.rebuild_status()["error"] is None
def test_require_token():
"""Le jeton (si défini) bloque les appels sans ou avec mauvais mauvais header."""
from fastapi import HTTPException
@ -851,25 +813,4 @@ def test_regen_cidr_accepts_comma_separated_list():
webapp.REGEN_CIDR = old
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

View File

@ -7,9 +7,8 @@ le téléchargement IGN). Endpoints :
GET /api/status → état de la génération en cours (ou dernière terminée)
GET /api/tiles → données de carte (index_tiles.json) pour l'affichage
en direct ; ?stamp=X → réponse allégée si inchangé
POST /api/sync → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync
depuis la machine de traitement) puis régénérer la
carte en arrière-plan
POST /api/sync → régénérer l'index local (vignettes + carte) en
arrière-plan (les tuiles sont servies à la demande)
POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option
all_missing=true) toutes les dalles LHD présentes dans
input/ qui manquent au moins une visualisation demandée.
@ -48,10 +47,11 @@ Architecture deux machines (cf. docs/DEPLOY_WEBAPP.md) : la webapp légère
(sans PDAL/GPU, ex. Raspberry Pi) délègue la génération à la machine de
traitement via LIDAR_GENERATION_URL — /api/generate, /api/preview et
/api/status sont transmis tels quels au service distant, qui exécute la même
webapp avec le pipeline complet. Les résultats reviennent par la commande de
synchronisation LIDAR_SYNC_CMD (rsync), puis /api/sync régénère vignettes et
carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
(webapp légère) protègent optionnellement les appels distants.
webapp avec le pipeline complet. Les images produites sont servies à la
demande depuis GENERATION_URL (cache local peuplé au fil des consultations)
puis /api/sync régénère vignettes et carte localement. LIDAR_API_TOKEN
(machine de traitement) + LIDAR_REMOTE_TOKEN (webapp légère) protègent
optionnellement les appels distants.
"""
import json
@ -90,9 +90,6 @@ GENERATION_URL = (os.environ.get("LIDAR_GENERATION_URL") or "").rstrip("/")
# (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel.
REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None
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) :
# liste de CIDR séparés par virgules, vide = restriction levée. Par défaut :
# boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte
@ -105,10 +102,6 @@ SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
REGEN_CIDR = (os.environ.get(
"LIDAR_REGEN_CIDR",
"127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") 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ésolution générée par /api/generate (cf. _build_command) : 0,2 m seule,
# la 0,5 m n'est plus produite. La détection des tuiles à compléter exige
@ -134,10 +127,6 @@ if 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.
@ -145,11 +134,11 @@ _assets_dir = OUTPUT_DIR / "assets"
_assets_dir.mkdir(parents=True, exist_ok=True)
# --- Cache à la demande : la visualisation peuple le cache local -------------
# Sans LIDAR_SYNC_CMD (pas de rsync), les images viennent de la machine de
# traitement par HTTP : une image absente du disque — ou plus ancienne que la
# version ?v= référencée (mtime ms de la source sur le worker) — est téléchar-
# gée depuis GENERATION_URL, écrite sur disque puis servie. Requêtes concur-
# rentes dédupliquées (verrou par fichier), flux réseau plafonné (sémaphore).
# Les images viennent de la machine de traitement par HTTP : une image absente
# du disque — ou plus ancienne que la version ?v= référencée (mtime ms de la
# source sur le worker) — est téléchargée depuis GENERATION_URL, écrite sur
# disque puis servie. Requêtes concurrentes dédupliquées (verrou par fichier),
# flux réseau plafonné (sémaphore).
from fastapi.staticfiles import StaticFiles as _StaticFiles
from starlette.concurrency import run_in_threadpool
@ -782,91 +771,46 @@ def tiles_data(stamp: Optional[float] = None):
}
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
# --- Rebuild de l'index en arrière-plan --------------------------------------
# Les tuiles sont servies à la demande (cache peuplé depuis la machine de
# traitement) : /api/sync ne fait plus qu'en régénérer l'index local
# (vignettes + carte).
_rebuild = {"running": False, "error": None, "done": None, "phase": None}
def _start_rebuild(with_sync):
"""Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index.
Sur la webapp légère, with_sync=True ramène d'abord les tuiles traitées
par la machine distante (rsync) ; l'échec éventuel de la synchronisation
est remonté dans l'état sans empêcher le rebuild des données locales.
"""
def _start_rebuild():
"""Lance en arrière-plan le rebuild de l'index (vignettes + carte)."""
if _rebuild["running"]:
raise HTTPException(409, "une synchronisation/rebuild est déjà en cours")
raise HTTPException(409, "un rebuild est déjà en cours")
def _run():
_rebuild["running"] = True
_rebuild["error"] = None
_rebuild["done"] = None
sync_error = None
if with_sync and SYNC_CMD:
_rebuild["phase"] = "sync"
try:
r = subprocess.run(SYNC_CMD, shell=True, capture_output=True,
text=True, timeout=4 * 3600)
if r.returncode != 0:
tail = (r.stderr or r.stdout or "").strip()[-500:]
sync_error = (f"synchronisation échouée (rc={r.returncode})"
+ (f" : {tail}" if tail else ""))
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
sync_error = f"synchronisation échouée : {e}"
_rebuild["phase"] = "index"
try:
from .index import build_index
build_index(OUTPUT_DIR)
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
_rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e)
else:
_rebuild["error"] = sync_error
_rebuild["error"] = str(e)
finally:
_rebuild["running"] = False
_rebuild["phase"] = None
_rebuild["done"] = time.time()
threading.Thread(target=_run, daemon=True).start()
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()
return {"demarré": True}
@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.
"""Régénère l'index local (vignettes + carte) en arrière-plan.
Sans LIDAR_SYNC_CMD (machine de traitement locale), se réduit à un simple
rebuild de l'index — même comportement que /api/rebuild.
Les tuiles étant servies à la demande, il n'y a plus de synchronisation à
exécuter : /api/sync se réduit à un rebuild de l'index (même
comportement que /api/rebuild).
"""
return _start_rebuild(with_sync=True)
return _start_rebuild()
@app.get("/api/sync")
@ -877,7 +821,7 @@ def sync_status():
@app.post("/api/rebuild", dependencies=[Depends(_require_token)])
def rebuild_index():
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
return _start_rebuild(with_sync=False)
return _start_rebuild()
@app.get("/api/rebuild")
@ -1090,9 +1034,9 @@ def export_tiles(req: ExportRequest):
"""Assemble des dalles adjacentes en image ou PDF (export.py).
Local par nature : les tuiles assemblées sont celles du cache output/
(la webapp légère possède sa copie via rsync) — aucune délégation à la
machine de traitement. Un seul export à la fois (CPU/mémoire limités sur
Raspberry Pi).
(la webapp légère les possède via le cache à la demande) — aucune
délégation à la machine de traitement. Un seul export à la fois
(CPU/mémoire limités sur Raspberry Pi).
"""
import re as _re
from .export import EXPORT_FORMATS, build_export
@ -1153,4 +1097,13 @@ def export_file(name: str):
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)
# HTTPS optionnel : la géolocalisation du navigateur (bouton ⌖, centrage
# GPS depuis un téléphone) n'est disponible qu'en contexte sécurisé.
# Définir LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE (certificat auto-signé)
# pour servir la carte en https://<hôte>:PORT ; le téléphone accepte
# l'avertissement « certificat non fiable » puis le GPS fonctionne.
_ssl_cert = os.environ.get("LIDAR_SSL_CERTFILE")
_ssl_key = os.environ.get("LIDAR_SSL_KEYFILE")
_ssl_kw = ({"ssl_certfile": _ssl_cert, "ssl_keyfile": _ssl_key}
if _ssl_cert and _ssl_key else {})
uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl_kw)