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:
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user