L'ancienne carte n'est pas une carte à tuiles : une div Leaflet par dalle,
rotée en CSS pour coller la grille Lambert 93 sur le Web Mercator, trois
paliers d'images choisis à la main, un plafond d'images pleine résolution et
une mosaïque d'overview pour boucher les trous au dézoom. Des centaines de
nœuds DOM, des AVIF de 2500² à 5000² décodés dans le navigateur, un LOD
maison — et rien de réutilisable hors de cette page.
Nouvelle image légère (Pillow + pyproj, ni GDAL ni PDAL, port 8975) servant
une pyramide XYZ EPSG:3857 au schéma OpenStreetMap, rendue à la demande
depuis les dalles et mise en cache dans output/index_xyz/ :
- tiles.py : grille XYZ, reprojection par transformation projective dalle par
dalle (calage mesuré < 1 px), choix du palier source parmi ceux que le
pipeline produit déjà (vignette, intermédiaire, quadrant, dalle), cache
périmé dès qu'une dalle contributrice est plus récente, marqueur .empty
pour les zones sans donnée, cache d'images sources à budget mémoire ;
- mapserve.py : /tiles/{couche}/{z}/{x}/{y}.png (256 px canonique) et
@2x.webp (512 px, interface), TileJSON, WMTS, josm.imagery.xml, CORS —
les rendus deviennent un fond d'imagerie pour JOSM, iD, QGIS, uMap ;
- mapui.py : une L.tileLayer par couche dans un pane isolé — LOD, cache et
animation natifs de Leaflet ; pile réordonnable (glisser avec barre
d'insertion, ou boutons ▲▼ au doigt), modes de fusion CSS par couche,
configuration figeable comme défaut de tous les navigateurs.
Deux amonts pour les déploiements en deux machines : LIDAR_SOURCE_URL
(webapp du pipeline — inventaire complet, dalles rapatriées à la demande) et
LIDAR_MAPS_URL (autre instance carte). Charge bornée et réglable, taillée
par défaut pour une petite machine : 2 rendus et 2 téléchargements
simultanés, 192 Mo de cache de sources.
Mesuré sur 849 dalles réelles : première tuile d'une zone 0,9–3,2 s
(téléchargement compris), ~1 ms ensuite ; un chemin OSM se superpose
exactement à la trace du rendu de pente, et les coutures entre tuiles
restent sous le bruit naturel du terrain.
L'interface historique (port 8973) n'est pas touchée : génération et export
y restent, les deux cartes coexistent.
620 lines
25 KiB
Python
620 lines
25 KiB
Python
"""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.
|
||
"""
|
||
|
||
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 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 © <a href="https://geoservices.ign.fr/lidarhd">IGN</a> '
|
||
'— 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)
|
||
|
||
_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")
|
||
|
||
# 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")
|
||
|
||
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
|
||
empty = data is None
|
||
if empty:
|
||
data = tiles_mod.transparent_tile(scale, fmt)
|
||
headers = dict(_cors_headers())
|
||
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
|
||
else "public, max-age=300, must-revalidate")
|
||
headers["X-Tile-Empty"] = "1" if empty 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' <bounds min-lat="{bounds[1]:.6f}" min-lon="{bounds[0]:.6f}"'
|
||
f' max-lat="{bounds[3]:.6f}" max-lon="{bounds[2]:.6f}"/>\n')
|
||
entries.append(
|
||
" <entry>\n"
|
||
f" <name>LiDAR HD — {_xml.escape(info['label'])}</name>\n"
|
||
f" <id>lidar-hd-{info['key'].replace('_', '-')}</id>\n"
|
||
" <type>tms</type>\n"
|
||
f" <url><![CDATA[{base}/tiles/{info['key']}/{{zoom}}/{{x}}/{{y}}.png]]></url>\n"
|
||
f" <min-zoom>{tiles_mod.TILE_MIN_Z}</min-zoom>\n"
|
||
f" <max-zoom>{tiles_mod.TILE_MAX_NATIVE_Z}</max-zoom>\n"
|
||
f"{b}"
|
||
f" <description lang=\"fr\">{_xml.escape(info['description'])}</description>\n"
|
||
f" <attribution-text>{_xml.escape(ATTRIBUTION_TEXT)}</attribution-text>\n"
|
||
" <attribution-url>https://geoservices.ign.fr/lidarhd</attribution-url>\n"
|
||
" <category>other</category>\n"
|
||
" </entry>")
|
||
xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<imagery xmlns="http://josm.openstreetmap.de/maps-1.0">\n'
|
||
+ "\n".join(entries) + "\n</imagery>\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""" <Layer>
|
||
<ows:Title>{_xml.escape(info['label'])}</ows:Title>
|
||
<ows:Abstract>{_xml.escape(info['description'])}</ows:Abstract>
|
||
<ows:WGS84BoundingBox>
|
||
<ows:LowerCorner>{bounds[0]:.6f} {bounds[1]:.6f}</ows:LowerCorner>
|
||
<ows:UpperCorner>{bounds[2]:.6f} {bounds[3]:.6f}</ows:UpperCorner>
|
||
</ows:WGS84BoundingBox>
|
||
<ows:Identifier>{info['key']}</ows:Identifier>
|
||
<Style isDefault="true"><ows:Identifier>default</ows:Identifier></Style>
|
||
<Format>image/png</Format>
|
||
<TileMatrixSetLink><TileMatrixSet>GoogleMapsCompatible</TileMatrixSet></TileMatrixSetLink>
|
||
<ResourceURL format="image/png" resourceType="tile"
|
||
template="{base}/tiles/{info['key']}/{{TileMatrix}}/{{TileCol}}/{{TileRow}}.png"/>
|
||
</Layer>""")
|
||
matrices = []
|
||
for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1):
|
||
matrices.append(f""" <TileMatrix>
|
||
<ows:Identifier>{z}</ows:Identifier>
|
||
<ScaleDenominator>{559082264.0287178 / (2 ** z):.10f}</ScaleDenominator>
|
||
<TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner>
|
||
<TileWidth>256</TileWidth>
|
||
<TileHeight>256</TileHeight>
|
||
<MatrixWidth>{2 ** z}</MatrixWidth>
|
||
<MatrixHeight>{2 ** z}</MatrixHeight>
|
||
</TileMatrix>""")
|
||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<Capabilities xmlns="http://www.opengis.net/wmts/1.0"
|
||
xmlns:ows="http://www.opengis.net/ows/1.1"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0.0">
|
||
<ows:ServiceIdentification>
|
||
<ows:Title>Carte LiDAR HD</ows:Title>
|
||
<ows:Abstract>{_xml.escape(ATTRIBUTION_TEXT)}</ows:Abstract>
|
||
<ows:ServiceType>OGC WMTS</ows:ServiceType>
|
||
<ows:ServiceTypeVersion>1.0.0</ows:ServiceTypeVersion>
|
||
<ows:AccessConstraints>Licence Ouverte 2.0</ows:AccessConstraints>
|
||
</ows:ServiceIdentification>
|
||
<Contents>
|
||
{chr(10).join(layers)}
|
||
<TileMatrixSet>
|
||
<ows:Identifier>GoogleMapsCompatible</ows:Identifier>
|
||
<ows:SupportedCRS>urn:ogc:def:crs:EPSG:6.18:3:3857</ows:SupportedCRS>
|
||
<WellKnownScaleSet>urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible</WellKnownScaleSet>
|
||
{chr(10).join(matrices)}
|
||
</TileMatrixSet>
|
||
</Contents>
|
||
<ServiceMetadataURL xlink:href="{base}/tiles/wmts.xml"/>
|
||
</Capabilities>
|
||
"""
|
||
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("/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)
|