"""Serveur de tuiles XYZ + carte « slippy » (image lidar-maps). Sert la pyramide de tuiles décrite dans `tiles.py` selon le schéma OpenStreetMap/Google Maps, donc directement utilisable comme fond d'imagerie dans JOSM, iD, QGIS, uMap, MapLibre ou OsmAnd : GET /tiles/{couche}/{z}/{x}/{y}.png 256 px, PNG RGBA (canonique) GET /tiles/{couche}/{z}/{x}/{y}@2x.webp 512 px, WebP (interface interne) GET /tiles/{couche}.json TileJSON 3.0.0 GET /tiles/wmts.xml capacités WMTS 1.0.0 (QGIS…) GET /tiles/josm.imagery.xml fichier d'imagerie JOSM GET /api/map/meta couches, zooms, emprise, version GET /api/map/tile?lat&lng dalle sous le point (fiche info) POST /api/map/warm pré-calcul d'une emprise GET / et /assets/* interface de consultation Les tuiles sont rendues à la demande puis mises en cache sur disque (`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un serveur amont : une tuile absente localement y est rapatriée (quelques dizaines de Ko) au lieu de rapatrier les dalles entières. Sur une petite machine (Raspberry Pi), deux réglages inversent la charge : - `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par le navigateur) et apparaît après passage de la maintenance ; - `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles (nouvelles ou régénérées) et met leur pyramide à jour en file d'attente, un rendu à la fois, à basse priorité (`os.nice`) et cadencé (`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan, `POST /api/map/warm` reste le pré-calcul manuel exhaustif. """ import json import logging import os import re import sys import threading import time import urllib.parse import urllib.request import xml.sax.saxutils as _xml from collections import deque from contextlib import asynccontextmanager from functools import lru_cache from pathlib import Path from typing import Optional from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from pydantic import BaseModel, Field from starlette.concurrency import run_in_threadpool from . import tiles as tiles_mod from .mapui import ASSETS_DIRNAME, render_html, ui_version, write_map_assets OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output")) PORT = int(os.environ.get("LIDAR_PORT", "8975")) # Serveur de tuiles amont (machine de traitement) — mode deux machines. MAPS_URL = (os.environ.get("LIDAR_MAPS_URL") or "").rstrip("/") ATTRIBUTION = os.environ.get( "LIDAR_ATTRIBUTION", 'LiDAR HD © IGN ' '— Licence Ouverte 2.0') ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").replace("—", "—") # Rendus simultanés : plafond bas par défaut — une carte se consulte souvent # depuis une petite machine (Raspberry Pi), et un rendu décode une dalle AVIF # de plusieurs dizaines de Mo. Les requêtes en excès attendent le sémaphore # sans consommer de CPU. `LIDAR_TILE_WORKERS` relève le plafond sur une # machine confortable. TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2)) _render_sem = threading.Semaphore(TILE_WORKERS) # --------------------------------------------------------------------------- # Maintenance de la pyramide en tâche de fond (petites machines) # --------------------------------------------------------------------------- # # `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge : # la navigation ne rend plus rien (lecture cache seule), une sondeur rescane # les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa # pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la # vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes. # Premier passage = dalle inconnue : toute la pyramide est reconstruite (les # tuiles déjà fraîches ne coûtent qu'un stat). def _env_flag(name): """Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel : testable sans recharger le module.""" return (os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on", "oui")) def _bg_conf(): """Réglages de la maintenance (relus au démarrage des fils, cf. doc).""" return { "interval": max(10.0, float(os.environ.get( "LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)), "max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get( "LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)), "scale": 1 if (os.environ.get( "LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2, "fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp") or "webp").strip(), "pause": max(0.0, float(os.environ.get( "LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)), "queue_max": max(64, int(os.environ.get( "LIDAR_TILE_BACKGROUND_QUEUE_MAX", "4096") or 4096)), } _bg = {"threads": [], "stop": threading.Event(), "queue": deque(), "queued": set(), "guard": threading.Lock(), "snapshot": None, "running": False, "stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0, "dernier_scan": None, "ajouts_dernier_scan": 0}} def _bg_enqueue(items): """Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée.""" added = 0 with _bg["guard"]: for item in items: if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]: continue _bg["queue"].append(item) _bg["queued"].add(item) added += 1 return added def _bg_pop(): with _bg["guard"]: if _bg["queue"]: item = _bg["queue"].popleft() _bg["queued"].discard(item) return item return None def background_scan(output_dir=None): """Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file. Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le mtime le plus récent des sources de chaque dalle : une dalle régénérée n'invalide que ses propres tuiles (même règle que le cache). """ output_dir = Path(output_dir or OUTPUT_DIR) conf = _bg_conf() snapshot = {} changed = {} for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items(): for (col, row), tiers in per_cell.items(): newest = 0.0 for group in tiers: for src in group: m = src.mtime() if m and m > newest: newest = m snapshot[(layer, col, row)] = newest if (_bg["snapshot"] is not None and _bg["snapshot"].get((layer, col, row)) == newest): continue changed.setdefault(layer, []).append((col, row)) _bg["snapshot"] = snapshot items = [] for layer, cells in changed.items(): for col, row in cells: bbox = tiles_mod.cell_bounds_wgs84(col, row) for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1): if not tiles_mod.zoom_supported(z, conf["scale"]): continue for x, y in tiles_mod.tiles_in_bounds(bbox, z): items.append((layer, z, x, y)) items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord added = _bg_enqueue(items) _bg["stats"]["scans"] += 1 _bg["stats"]["dernier_scan"] = time.time() _bg["stats"]["ajouts_dernier_scan"] = added cells_changed = sum(len(v) for v in changed.values()) if items: logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) " f"modifiée(s), {added} tuile(s) en file") return added def _bg_process_one(): """Traite une tuile de la file. Vrai si elle a dû être rendue.""" item = _bg_pop() if item is None: return False layer, z, x, y = item conf = _bg_conf() _data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]) if state != "pending": _bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1 return False tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]) _bg["stats"]["rendues"] += 1 return True def _bg_worker(): try: os.nice(10) # consommation minimale : le rendu cède aux autres usages except (AttributeError, OSError): # pragma: no cover — nice indisponible pass while not _bg["stop"].is_set(): try: rendered = _bg_process_one() except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file logger.warning(f"Maintenance des tuiles : rendu impossible ({e})") rendered = False if rendered: _bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause else: _bg["stop"].wait(0.5) def _bg_poller(): while not _bg["stop"].is_set(): try: background_scan() except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL logger.warning(f"Maintenance des tuiles : scan impossible ({e})") _bg["stop"].wait(_bg_conf()["interval"]) def start_background(): """Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés.""" if _bg["running"]: return _bg["stop"].clear() _bg["running"] = True threads = [threading.Thread(target=_bg_poller, daemon=True, name="lidar-bg-scan")] threads += [threading.Thread(target=_bg_worker, daemon=True, name=f"lidar-bg-render-{i}") for i in range(TILE_WORKERS)] for t in threads: t.start() _bg["threads"] = threads conf = _bg_conf() logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} " f"@{conf['scale']}x {conf['fmt']}, scan toutes les " f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s " f"entre rendus") def stop_background(): _bg["stop"].set() _bg["running"] = False @lru_cache(maxsize=8) def _transparent_tile(scale=1, fmt="png"): return tiles_mod.transparent_tile(scale, fmt) @asynccontextmanager async def _lifespan(_app): if _env_flag("LIDAR_TILE_BACKGROUND"): start_background() yield stop_background() _LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$") _NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$") _MAX_UPSTREAM_BYTES = 8 * 1024 * 1024 logger = logging.getLogger("lidar") if not logger.handlers: _h = logging.StreamHandler(sys.stdout) _h.setFormatter(logging.Formatter("%(message)s")) logger.addHandler(_h) logger.setLevel(logging.INFO) app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan) # Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli # (dev, paquet installé sans bake). _assets_dir = Path(__file__).resolve().parent / ASSETS_DIRNAME if not (_assets_dir / "app.js").is_file(): try: write_map_assets(_assets_dir) except OSError: import tempfile _assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets" write_map_assets(_assets_dir) logger.info(f"Carte LiDAR (tuiles XYZ) — sortie {OUTPUT_DIR}" + (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local") + f", {TILE_WORKERS} rendu(s) simultané(s)" + f", {tiles_mod.FETCH_WORKERS} téléchargement(s) simultané(s)" + f", cache sources {tiles_mod.SOURCE_CACHE_BYTES // (1024 * 1024)} Mo") # --------------------------------------------------------------------------- # Utilitaires # --------------------------------------------------------------------------- @app.middleware("http") async def _cors(request: Request, call_next): """CORS ouvert sur les tuiles : exigé par iD, uMap et MapLibre.""" if request.method == "OPTIONS" and request.url.path.startswith("/tiles/"): return Response(status_code=204, headers=_cors_headers()) response = await call_next(request) if request.url.path.startswith("/tiles/"): response.headers.update(_cors_headers()) return response def _cors_headers(): return {"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*"} def _base_url(request: Request): """URL publique du service (proxy inverse honoré).""" proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("x-forwarded-host") or request.headers.get("host") if not host: host = f"{request.url.hostname}:{request.url.port or PORT}" return f"{proto}://{host}" def _layer_or_404(layer): if not _LAYER_RE.match(layer or ""): raise HTTPException(404, "couche inconnue") if layer not in tiles_mod.available_layers(OUTPUT_DIR): raise HTTPException(404, f"couche indisponible : {layer}") return layer def _layer_infos(): """Couches disponibles + libellés/descriptions du registre partagé.""" from .index import VIZ_LABELS, VIZ_LEGENDS out = [] for key in tiles_mod.available_layers(OUTPUT_DIR): legend = VIZ_LEGENDS.get(key, {}) out.append({"key": key, "label": VIZ_LABELS.get(key, key), "description": legend.get("description", "")}) return out # --- disjoncteur amont (mêmes réglages que webapp.py) ---------------------- _UPSTREAM = {"fails": 0, "until": 0.0} _UPSTREAM_OFFLINE_AFTER = 3 _UPSTREAM_OFFLINE_SECONDS = 120.0 def _upstream_offline(): return time.time() < _UPSTREAM["until"] def _upstream_mark(ok): if ok: _UPSTREAM.update(fails=0, until=0.0) return _UPSTREAM["fails"] += 1 if _UPSTREAM["fails"] >= _UPSTREAM_OFFLINE_AFTER and _UPSTREAM["until"] == 0.0: _UPSTREAM["until"] = time.time() + _UPSTREAM_OFFLINE_SECONDS logger.info("Serveur de tuiles amont injoignable — tentatives suspendues 2 min") def _fetch_upstream(rel_path): """Rapatrie une tuile depuis LIDAR_MAPS_URL (None si indisponible).""" if not MAPS_URL or _upstream_offline(): return None url = f"{MAPS_URL}/{rel_path.lstrip('/')}" try: req = urllib.request.Request(url, headers={"User-Agent": "lidar-maps-cache"}) with urllib.request.urlopen(req, timeout=20) as r: data = r.read(_MAX_UPSTREAM_BYTES + 1) if len(data) > _MAX_UPSTREAM_BYTES: raise IOError("tuile amont trop volumineuse") _upstream_mark(True) return data except Exception as e: # noqa: BLE001 — amont éteint : rendu local/vide _upstream_mark(False) logger.debug(f"Tuile amont indisponible ({url}) : {e}") return None # --- rendu : déduplication des requêtes concurrentes ------------------------ _locks = {} _locks_guard = threading.Lock() def _acquire_lock(key): """Verrou par tuile, compté : deux requêtes simultanées n'en rendent qu'une. Le compteur évite deux écueils : un dictionnaire qui grossit sans fin (des millions de tuiles) et un verrou retiré alors qu'un autre fil l'attend encore — ce qui autoriserait deux rendus concurrents de la même tuile. """ with _locks_guard: entry = _locks.get(key) if entry is None: entry = _locks[key] = [threading.Lock(), 0] entry[1] += 1 return entry def _release_lock(key, entry): with _locks_guard: entry[1] -= 1 if entry[1] <= 0: _locks.pop(key, None) def _tile_bytes(layer, z, x, y, scale, fmt): """Tuile encodée : cache → rendu local → amont. None si zone vide.""" key = (layer, z, x, y, scale, fmt) entry = _acquire_lock(key) try: with entry[0]: try: with _render_sem: data = tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, scale, fmt) except Exception as e: # noqa: BLE001 — une tuile ne casse pas la carte logger.warning(f"Rendu de tuile impossible ({layer} {z}/{x}/{y}) : {e}") data = None if data is None and MAPS_URL: suffix = "@2x" if scale == 2 else "" data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}") if data: tiles_mod._write_atomic( tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y, scale, fmt), data) finally: _release_lock(key, entry) return data _MEDIA = {"png": "image/png", "webp": "image/webp", "avif": "image/avif"} # --------------------------------------------------------------------------- # Tuiles # --------------------------------------------------------------------------- @app.get("/tiles/{layer}/{z}/{x}/{name}") async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None): """Tuile XYZ (schéma OSM). `name` = `{y}.png` ou `{y}@2x.webp`.""" m = _NAME_RE.match(name) if not m: raise HTTPException(404, "nom de tuile invalide") y = int(m.group(1)) scale = 2 if m.group(2) else 1 fmt = m.group(3) _layer_or_404(layer) if not tiles_mod.zoom_supported(z, scale): raise HTTPException(404, f"zoom hors plage ({tiles_mod.TILE_MIN_Z}" f"–{tiles_mod.TILE_MAX_NATIVE_Z})") n = 2 ** z if not (0 <= x < n and 0 <= y < n): raise HTTPException(404, "tuile hors grille") if _env_flag("LIDAR_TILE_CACHE_ONLY"): # Mode cache seule : aucune navigation ne déclenche de rendu (ni amont). data, state = await run_in_threadpool( tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt) pending = state == "pending" empty = state == "empty" else: data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt) pending = False empty = data is None if empty or pending: data = _transparent_tile(scale, fmt) headers = dict(_cors_headers()) if pending: # Tuile à venir (rendue par la maintenance) : jamais mémorisée par le # navigateur, la prochaine navigation la redemande et la trouve prête. headers["Cache-Control"] = "no-store" headers["X-Tile-Pending"] = "1" else: headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v else "public, max-age=300, must-revalidate") headers["X-Tile-Empty"] = "1" if (empty or pending) else "0" return Response(content=data, media_type=_MEDIA[fmt], headers=headers) # --------------------------------------------------------------------------- # Découverte : TileJSON, WMTS, JOSM # --------------------------------------------------------------------------- @app.get("/tiles/josm.imagery.xml") def josm_imagery(request: Request): """Fichier d'imagerie JOSM : ajoute toutes les couches en une fois.""" base = _base_url(request) bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) entries = [] for info in _layer_infos(): b = "" if bounds: b = (f' \n') entries.append( " \n" f" LiDAR HD — {_xml.escape(info['label'])}\n" f" lidar-hd-{info['key'].replace('_', '-')}\n" " tms\n" f" \n" f" {tiles_mod.TILE_MIN_Z}\n" f" {tiles_mod.TILE_MAX_NATIVE_Z}\n" f"{b}" f" {_xml.escape(info['description'])}\n" f" {_xml.escape(ATTRIBUTION_TEXT)}\n" " https://geoservices.ign.fr/lidarhd\n" " other\n" " ") xml = ('\n' '\n' + "\n".join(entries) + "\n\n") return Response(content=xml, media_type="application/xml", headers=_cors_headers()) @app.get("/tiles/wmts.xml") def wmts_capabilities(request: Request): """Capacités WMTS 1.0.0 (QGIS, ArcGIS) sur la grille GoogleMapsCompatible.""" base = _base_url(request) bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0] layers = [] for info in _layer_infos(): layers.append(f""" {_xml.escape(info['label'])} {_xml.escape(info['description'])} {bounds[0]:.6f} {bounds[1]:.6f} {bounds[2]:.6f} {bounds[3]:.6f} {info['key']} image/png GoogleMapsCompatible """) matrices = [] for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1): matrices.append(f""" {z} {559082264.0287178 / (2 ** z):.10f} -20037508.3427892 20037508.3427892 256 256 {2 ** z} {2 ** z} """) xml = f""" Carte LiDAR HD {_xml.escape(ATTRIBUTION_TEXT)} OGC WMTS 1.0.0 Licence Ouverte 2.0 {chr(10).join(layers)} GoogleMapsCompatible urn:ogc:def:crs:EPSG:6.18:3:3857 urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible {chr(10).join(matrices)} """ return Response(content=xml, media_type="application/xml", headers=_cors_headers()) @app.get("/tiles/{layer}.json") def tilejson(layer: str, request: Request): """TileJSON 3.0.0 (MapLibre, QGIS, Felt…).""" _layer_or_404(layer) from .index import VIZ_LABELS, VIZ_LEGENDS base = _base_url(request) bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0] center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2, min(tiles_mod.TILE_MAX_NATIVE_Z, 15)] return JSONResponse({ "tilejson": "3.0.0", "name": f"LiDAR HD — {VIZ_LABELS.get(layer, layer)}", "description": VIZ_LEGENDS.get(layer, {}).get("description", ""), "attribution": ATTRIBUTION, "scheme": "xyz", "tiles": [f"{base}/tiles/{layer}/{{z}}/{{x}}/{{y}}.png"], "minzoom": tiles_mod.TILE_MIN_Z, "maxzoom": tiles_mod.TILE_MAX_NATIVE_Z, "bounds": bounds, "center": center, }, headers=_cors_headers()) # --------------------------------------------------------------------------- # Configuration par défaut de la pile de couches # --------------------------------------------------------------------------- # L'état réglé depuis la carte (ordre, visibilité, opacité, fusion) peut être # figé comme configuration servie à TOUT nouveau navigateur : le réglage utile # ne se perd pas dans un localStorage et se partage sans lien. DEFAULTS_FILE = OUTPUT_DIR / ".map-defaults.json" _defaults_lock = threading.Lock() def _load_defaults(): """Configuration par défaut enregistrée, ou None si aucune.""" try: data = json.loads(DEFAULTS_FILE.read_text(encoding="utf-8")) except (OSError, ValueError): return None return data if isinstance(data, dict) else None def _clamp01(value, fallback=1.0): try: return min(1.0, max(0.0, float(value))) except (TypeError, ValueError): return fallback BLEND_MODES = ("normal", "multiply", "screen", "overlay", "soft-light", "hard-light", "difference", "luminosity") def _sanitize_defaults(req): """Retient ce qui est connu et borné : couches réelles, opacités 0–1, fusions valides.""" known = set(tiles_mod.available_layers(OUTPUT_DIR)) order = [k for k in (req.order or []) if k in known] for key in sorted(known - set(order)): order.append(key) on = [k for k in (req.on or []) if k in known] opacity = {k: _clamp01(v) for k, v in (req.opacity or {}).items() if k in known} blend = {k: v for k, v in (req.blend or {}).items() if k in known and v in BLEND_MODES} base_in = req.base if isinstance(req.base, dict) else {} base = {"on": bool(base_in.get("on", True)), "opacity": _clamp01(base_in.get("opacity"), 0.85), "dark": bool(base_in.get("dark", True))} stack = req.stack_blend if req.stack_blend in BLEND_MODES else "normal" return {"order": order, "on": on, "opacity": opacity, "blend": blend, "base": base, "stack_blend": stack, "saved_at": time.time()} class DefaultsRequest(BaseModel): order: list = Field(default_factory=list, description="ordre de pile, du bas vers le haut") on: list = Field(default_factory=list, description="couches allumées") opacity: dict = Field(default_factory=dict, description="opacité par couche, 0–1") blend: dict = Field(default_factory=dict, description="mode de fusion par couche") base: dict = Field(default_factory=dict, description="fond de carte : on, opacity, dark") stack_blend: str = Field("normal", description="fusion de la pile sur le fond") @app.get("/api/map/defaults") def get_defaults(): """Configuration par défaut servie aux nouveaux navigateurs.""" return {"defaults": _load_defaults()} @app.post("/api/map/defaults") def set_defaults(req: DefaultsRequest): """Fige l'état courant comme configuration par défaut (tous navigateurs).""" data = _sanitize_defaults(req) with _defaults_lock: tmp = DEFAULTS_FILE.with_suffix(".tmp") DEFAULTS_FILE.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") os.replace(tmp, DEFAULTS_FILE) logger.info(f"Configuration de pile par défaut enregistrée : " f"{len(data['on'])} couche(s) allumée(s)") return {"enregistré": True, "defaults": data} @app.delete("/api/map/defaults") def clear_defaults(): """Revient aux réglages du registre (index.py).""" with _defaults_lock: existed = DEFAULTS_FILE.exists() DEFAULTS_FILE.unlink(missing_ok=True) return {"supprimé": existed} # --------------------------------------------------------------------------- # API de la carte # --------------------------------------------------------------------------- @app.get("/api/map/meta") def map_meta(): """Couches, zooms, emprise et version — tout l'état initial de l'interface.""" from .index import DEFAULT_BLEND, DEFAULT_OPACITY, default_layers_present infos = _layer_infos() keys = [i["key"] for i in infos] saved = _load_defaults() # Configuration figée depuis la carte, sinon réglages du registre (index.py) default_layers = saved["on"] if saved else default_layers_present(keys) default_opacity = saved["opacity"] if saved else dict(DEFAULT_OPACITY) default_blend = saved["blend"] if saved else dict(DEFAULT_BLEND) return { "layers": infos, "default_layers": default_layers, "default_opacity": default_opacity, "default_blend": default_blend, "default_order": saved["order"] if saved else None, "default_base": saved["base"] if saved else None, "default_stack_blend": saved["stack_blend"] if saved else "normal", "defaults_saved": bool(saved), # L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en # 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique. "tile_url": "tiles/{layer}/{z}/{x}/{y}@2x.webp", "tile_size": tiles_mod.TILE_SIZE * 2, "zoom_offset": -1, "min_zoom": tiles_mod.TILE_MIN_Z, "max_native_zoom": tiles_mod.TILE_MAX_NATIVE_Z - 1, "bounds": tiles_mod.grid_bounds_wgs84(OUTPUT_DIR), "stamp": tiles_mod.tiles_stamp(OUTPUT_DIR), "attribution": ATTRIBUTION, "attribution_text": ATTRIBUTION_TEXT, "ui_version": ui_version(), } @app.get("/api/map/tile") def map_tile_info(lat: float, lng: float): """Dalle LHD sous un point WGS84 + métadonnées de génération.""" from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles x, y = tiles_mod.wgs84_to_l93(lng, lat) col, row = int(x // 1000), int(y // 1000) + 1 best = None for t in scan_tiles(OUTPUT_DIR / "visualisations"): if (t["col"], t["row"]) != (col, row): continue if best is None or t["resolution"] < best["resolution"]: best = t if best is None: return {"found": False, "col": col, "row": row} meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM") return { "found": True, "col": col, "row": row, "name": best["basename"], "resolution": best["resolution"], "method": meta.get("method"), "generated": meta.get("generated"), "layers": sorted(best["viz"]), "suffix": _res_suffix_str(best["resolution"]), } class WarmRequest(BaseModel): layers: list = Field(default_factory=list, description="couches à pré-calculer (défaut : toutes)") z_min: int = Field(tiles_mod.TILE_MIN_Z, description="zoom minimal") z_max: int = Field(16, description="zoom maximal (19 = résolution native)") bounds: Optional[list] = Field(None, description="[ouest, sud, est, nord] WGS84 ; " "défaut : emprise des dalles") scale: int = Field(1, description="1 = tuiles 256 px, 2 = 512 px (@2x)") fmt: str = Field("png", description="png, webp ou avif") _warm = {"running": False, "done": None, "result": None} @app.post("/api/map/warm") def map_warm(req: WarmRequest): """Pré-calcule des tuiles en tâche de fond (accélère la 1re consultation).""" if _warm["running"]: raise HTTPException(409, "un pré-chauffage est déjà en cours") layers = [l for l in req.layers if _LAYER_RE.match(str(l))] or \ tiles_mod.available_layers(OUTPUT_DIR) if req.fmt not in _MEDIA: raise HTTPException(400, f"format invalide : {req.fmt}") _warm.update(running=True, done=None, result=None) def _run(): try: _warm["result"] = tiles_mod.warm( OUTPUT_DIR, layers, req.z_min, req.z_max, req.bounds, max(1, min(2, req.scale)), req.fmt) except Exception as e: # noqa: BLE001 — remonté via l'état _warm["result"] = {"erreur": str(e)} finally: _warm["running"] = False _warm["done"] = time.time() threading.Thread(target=_run, daemon=True).start() return {"demarré": True, "couches": layers} @app.get("/api/map/warm") def map_warm_status(): return dict(_warm) @app.get("/api/map/background") def background_status(): """État de la maintenance de fond : file, compteurs, configuration.""" with _bg["guard"]: queued = len(_bg["queue"]) return {"active": _bg["running"], "en_file": queued, "configuration": _bg_conf(), "stats": dict(_bg["stats"])} @app.post("/api/map/background") def background_trigger(): """Force un scan immédiat des dalles (rendu ensuite en tâche de fond).""" if not _env_flag("LIDAR_TILE_BACKGROUND"): raise HTTPException(409, "maintenance de fond inactive " "(LIDAR_TILE_BACKGROUND)") return {"tuiles_en_file": background_scan()} @app.get("/healthz") def healthz(): return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))} # --------------------------------------------------------------------------- # Interface # --------------------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) def root(): return HTMLResponse(render_html(), headers={ "Cache-Control": "no-cache, no-store, must-revalidate"}) @app.get("/assets/{file_path:path}") def assets(file_path: str, v: str = None): """Assets de l'interface (revalidés : ils suivent l'image, pas le cache).""" base = _assets_dir.resolve() p = (_assets_dir / file_path).resolve() if base not in p.parents or not p.is_file(): raise HTTPException(404, f"asset introuvable : {file_path}") return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"}) if __name__ == "__main__": import uvicorn _cert = os.environ.get("LIDAR_SSL_CERTFILE") _key = os.environ.get("LIDAR_SSL_KEYFILE") _ssl = {"ssl_certfile": _cert, "ssl_keyfile": _key} if _cert and _key else {} uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl)