Files
lidar_rendu/lidar_pipeline/index.py
Antoine Jacquin 422f58d772 Split webapp for Raspberry Pi deployment, remote generation API and sync
La webapp (carte + vignettes) et la génération de tuiles se déploient sur
deux machines : image légère Dockerfile.webapp (FastAPI + Pillow AVIF
natif + pyproj) sur Raspberry Pi, pipeline complet sur la machine de
traitement. LIDAR_GENERATION_URL délègue /api/generate, /api/preview et
/api/status ; /api/sync ramène les tuiles par rsync puis régénère
vignettes et index localement. Token partagé optionnel
(LIDAR_API_TOKEN/LIDAR_REMOTE_TOKEN). Retire du dépôt les journaux
internes (.swival, audit-findings) et les données (data/, notebooks/).
Doc : docs/DEPLOY_WEBAPP.md.
2026-09-02 19:44:39 +02:00

2344 lines
92 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Carte continue interactive des tuiles LiDAR traitées.
Génère l'interface web de consultation (servie par webapp.py via --serve) :
- output/index.html : coquille HTML minimaliste embarquant les données
- output/assets/app.css : styles (panneaux flottants type SIG)
- output/assets/app.js : logique carte (Leaflet), couches, infos tuiles
Fonctionnalités :
- Carte continue zoom/pan (Leaflet), tuiles rotées selon la projection
Lambert 93, vignettes ↔ images pleine résolution selon le zoom.
- Panneau de couches superposables avec opacité individuelle et
réordonnancement par glisser-déposer (persisté en localStorage).
- Panneau d'infos par tuile : résolution, méthode de classification du sol
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
dessin d'un rectangle → téléchargement IGN + traitement.
Intégration:
- Appelé automatiquement à la fin de process_all() dans pipeline.py
- Régénération solo via --rebuild-index dans cli.py
"""
import json
import logging
import re
import time
from datetime import datetime
from pathlib import Path
logger = logging.getLogger("lidar")
# Noms d'affichage (français) pour le panneau de couches.
# Clé = mot-clé dans le nom de fichier de sortie (post-basename).
VIZ_LABELS = {
'hillshade_multi': 'Hillshade multidirectionnel',
'slope': 'Pente',
'aspect': 'Aspect',
'mslrm': 'MSRM (relief multi-échelle)',
'sailore': 'SAILORE (LRM adaptatif)',
'positive_openness': 'Openness positive',
'negative_openness': 'Openness négative',
'svf': 'Sky-View Factor',
'roughness': 'Rugosité',
'wavelet': 'Ondelette',
'flow_acc': 'Accumulation d\'écoulement',
'solar': 'Éclairage solaire',
'anomaly': 'Carte d\'anomalies',
'ortho': 'Orthophoto IGN',
'topo': 'Carte topographique IGN',
}
# Couche activée par défaut à l'ouverture de la carte.
DEFAULT_VIZ = 'hillshade_multi'
# Visualisations proposées dans le panneau de couches et le sélecteur de
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
# les couches réellement proposées à l'affichage (voir _render_html).
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
# cf. _expected_output_path dans pipeline.py).
KEYWORD_TO_STEP = {
'hillshade_multi': 'hillshade',
'positive_openness': 'pos_open',
'negative_openness': 'neg_open',
}
# Correspondance inverse : nom d'étape --only → mot-clé de fichier de sortie.
STEP_TO_KEYWORD = {step: kw for kw, step in KEYWORD_TO_STEP.items()}
def step_to_keyword(step):
"""Nom d'étape du pipeline (ex: 'pos_open') → mot-clé de fichier ('positive_openness')."""
return STEP_TO_KEYWORD.get(step, step)
def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
"""Cellules (col, row) disposant de TOUTES les visualisations demandées.
Une cellule est complète si, pour chaque résolution de `resolutions`, un
dossier de visualisations lui correspond et contient chaque mot-clé de
`viz_keys` (ex: 'aspect', 'hillshade_multi'). Sert à distinguer les tuiles
réellement terminées de celles à compléter : une tuile existante mais
incomplète (visualisation ou résolution manquante) reste à traiter.
Returns:
Ensemble des (col, row) complets.
"""
by_cell = {}
for t in scan_tiles(vis_dir):
per_res = by_cell.setdefault((t['col'], t['row']), {})
per_res.setdefault(t['resolution'], set()).update(t['viz'].keys())
return {cell for cell, per_res in by_cell.items()
if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)}
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
# carte — les autres viz restent disponibles en repli dalle entière.
# Liste vide = découper toutes les visualisations disponibles.
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'wavelet')
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
# speed 0 (lent/meilleur) → 10 (rapide) ; 6+8 quasi identique en taille,
# on garde 8 (2× plus rapide) pour 2500×2500 px.
_SUBTILE_AVIF_QUALITY = 55
_SUBTILE_AVIF_SPEED = 8
# Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli.
_VIZ_FALLBACK_ORDER = [
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
'negative_openness', 'aspect', 'sailore', 'roughness',
'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo',
]
# Regex pour parser les coordonnées tuile dans le basename LHD.
# LHD_FXX_{COL}_{ROW}_PTS_LAMB93_IGN69 (COL/ROW en km, Lambert 93)
_RE_LHD_COORDS = re.compile(r'^LHD_FXX_(\d+)_(\d+)_PTS_LAMB93')
def parse_basename_coords(name):
"""Extrait les coordonnées tuile (col, row en km) depuis un basename.
Args:
name: basename potentiel (ex: 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69')
ou nom de dossier avec suffixe résolution ('..._r0p2').
Returns:
(col_km, row_km) ou None si le nom ne correspond pas au pattern LHD.
"""
m = _RE_LHD_COORDS.match(name)
if not m:
return None
return int(m.group(1)), int(m.group(2))
def _strip_res_suffix(dirname):
"""Sépare le basename de base et la résolution d'un nom de dossier de viz.
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69' → (basename, 0.5)
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2' → (basename, 0.2)
Returns:
(basename_without_suffix, resolution_float) ou (dirname, 0.5) si pas de suffixe.
"""
m = re.match(r'^(.+?)_r(\d+p\d+)$', dirname)
if m:
res_str = m.group(2).replace('p', '.')
try:
return m.group(1), float(res_str)
except ValueError:
pass
return dirname, 0.5
def _res_suffix_str(resolution):
"""Suffixe de nommage d'une résolution (miroir de pipeline._res_suffix)."""
if resolution == 0.5:
return ''
return '_r' + f'{resolution}'.replace('.', 'p')
def scan_tiles(vis_dir):
"""Scanne le dossier des visualisations pour inventorier les tuiles traitées.
Args:
vis_dir: Path vers output/visualisations/
Returns:
Liste de dictionnaires:
{basename, col, row, resolution, dir_path,
viz: {viz_key: {filename, ext}}, dir_name}
Triée par (resolution, row, col).
"""
vis_dir = Path(vis_dir)
if not vis_dir.is_dir():
return []
tiles = []
for entry in sorted(vis_dir.iterdir()):
if not entry.is_dir():
continue
coords = parse_basename_coords(entry.name)
if coords is None:
continue
col, row = coords
basename, resolution = _strip_res_suffix(entry.name)
# Liste les fichiers image de visualisation dans le dossier.
viz = {}
for f in sorted(entry.iterdir()):
if not f.is_file():
continue
# Détection extension AVIF/WebP
ext = None
low = f.name.lower()
for e in ('.avif', '.webp'):
if low.endswith(e):
ext = e.lstrip('.')
break
if ext is None:
continue
# viz_key = nom sans le préfixe basename_ ni l'extension
stem = f.name[:-len('.' + ext)]
prefix = basename + '_'
if not stem.startswith(prefix):
continue
viz_key = stem[len(prefix):]
viz[viz_key] = {'filename': f.name, 'ext': ext}
if not viz:
# Dossier vide ou sans image valide → ignoré
continue
tiles.append({
'basename': basename,
'col': col,
'row': row,
'resolution': resolution,
'dir_name': entry.name,
'dir_path': str(entry),
'viz': viz,
})
tiles.sort(key=lambda t: (t['resolution'], -t['row'], t['col']))
return tiles
def compute_bbox(tiles):
"""Calcule la bounding box (en km) couverte par les tuiles.
Returns:
Dict {min_col, max_col, min_row, max_row} ou None si aucune tuile.
"""
if not tiles:
return None
cols = [t['col'] for t in tiles]
rows = [t['row'] for t in tiles]
return {
'min_col': min(cols),
'max_col': max(cols),
'min_row': min(rows),
'max_row': max(rows),
}
def compute_zones(tiles, proximity_threshold=15):
"""Regroupe les tuiles en zones géographiques par clustering de proximité.
Les tuiles à moins de proximity_threshold km les unes des autres
sont regroupées dans la même zone. Les zones sont triées par taille
décroissante (plus grande zone en premier).
Args:
tiles: Liste de dictionnaires de tuiles.
proximity_threshold: Distance maximale en km pour regrouper deux tuiles.
Returns:
Liste de dictionnaires de zones:
{label, tiles, bbox}
"""
if not tiles:
return []
# Union-Find pour le clustering
parent = list(range(len(tiles)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px != py:
parent[px] = py
# Regrouper les tuiles proches
for i in range(len(tiles)):
for j in range(i + 1, len(tiles)):
dc = abs(tiles[i]['col'] - tiles[j]['col'])
dr = abs(tiles[i]['row'] - tiles[j]['row'])
if max(dc, dr) <= proximity_threshold:
union(i, j)
# Construire les zones
zone_members = {}
for i in range(len(tiles)):
root = find(i)
if root not in zone_members:
zone_members[root] = []
zone_members[root].append(tiles[i])
zones = []
for i, zone_tiles in enumerate(zone_members.values(), 1):
zone_bbox = compute_bbox(zone_tiles)
zones.append({
'label': f'Zone {i} ({len(zone_tiles)} tuiles)',
'tiles': zone_tiles,
'bbox': zone_bbox,
})
# Trier par taille décroissante
zones.sort(key=lambda z: len(z['tiles']), reverse=True)
return zones
def _approx_l93_to_wgs84(x_m, y_m):
"""Approximation affine Lambert 93 → WGS84 (fallback sans rasterio).
Origine exacte: (700000, 6600000) L93 ↔ (3.0°E, 46.5°N).
Précision de l'ordre du km — utilisée seulement si rasterio/PROJ
est indisponible (jamais le cas dans le Docker).
"""
import math
lat = 46.5 + (y_m - 6600000.0) / 111320.0
lon = 3.0 + (x_m - 700000.0) / (111320.0 * math.cos(math.radians(47.0)))
return lon, lat
def _approx_wgs84_to_l93(lon, lat):
"""Approximation affine WGS84 → Lambert 93 (inverse exacte du précédent).
Précision de l'ordre du km — repli sans rasterio ni pyproj (webapp
légère, cf. bbox_to_cells dans webapp.py).
"""
import math
y = (lat - 46.5) * 111320.0 + 6600000.0
x = (lon - 3.0) * (111320.0 * math.cos(math.radians(47.0))) + 700000.0
return x, y
def attach_gps_bounds(tiles):
"""Attache à chaque tuile ses coins GPS pour l'affichage Leaflet.
Chaque tuile 1×1 km est définie par son coin nord-ouest en km L93
(col, row) → X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
(Vérifié sur les bounds des DTM : X_min = col×1000, Y_max = row×1000.)
Utilise rasterio.warp (conversion PROJ exacte) si disponible, sinon
pyproj, sinon l'approximation affine _approx_l93_to_wgs84 (webapp
légère sans GDAL — précision ~km).
Ajoute à chaque tuile:
corners: [[lat, lon] × 4] dans l'ordre SW, SE, NE, NW
bounds : [[lat_sud, lon_ouest], [lat_nord, lon_est]]
"""
# Coins SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000
xs = []
ys = []
for t in tiles:
xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000,
(t['col'] + 1) * 1000, t['col'] * 1000])
ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000,
t['row'] * 1000, t['row'] * 1000])
try:
from rasterio.warp import transform as warp_transform
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
ok = True
except ImportError:
try:
from pyproj import Transformer
transformer = Transformer.from_crs('EPSG:2154', 'EPSG:4326',
always_xy=True)
lons, lats = transformer.transform(xs, ys)
ok = True
except ImportError:
logger.debug("Coins GPS approximatifs (rasterio et pyproj indisponibles)")
lons = None
lats = None
ok = False
except Exception as e:
logger.debug(f"Coins GPS approximatifs (rasterio indisponible: {e})")
lons = None
lats = None
ok = False
for i, t in enumerate(tiles):
if ok:
corners = [[lats[4 * i], lons[4 * i]],
[lats[4 * i + 1], lons[4 * i + 1]],
[lats[4 * i + 2], lons[4 * i + 2]],
[lats[4 * i + 3], lons[4 * i + 3]]]
else:
corners = []
for cx in (t['col'] * 1000, (t['col'] + 1) * 1000):
for cy in ((t['row'] - 1) * 1000, t['row'] * 1000):
lon, lat = _approx_l93_to_wgs84(cx, cy)
corners.append([lat, lon])
# Reordonner SW, SE, NE, NW (la boucle donne SW, NW, SE, NE)
corners = [corners[0], corners[2], corners[3], corners[1]]
t['corners'] = corners
t['bounds'] = [[min(c[0] for c in corners), min(c[1] for c in corners)],
[max(c[0] for c in corners), max(c[1] for c in corners)]]
return ok
def _mtime(path):
"""Mtime d'un fichier, ou None si inaccessible."""
try:
return Path(path).stat().st_mtime
except OSError:
return None
def _cached_file_fresh(path, src_mtime):
"""True si un fichier cache existe et est plus récent que sa source.
Sert à invalider vignettes et sous-tuiles quand une tuile est recalculée :
l'image source (AVIF/WebP) étant réécrite, sa mtime devient plus récente
que celle du cache, qui doit alors être régénéré.
"""
cached_mtime = _mtime(path)
if cached_mtime is None:
return False
return src_mtime is None or cached_mtime >= src_mtime
def generate_thumbnail(src_path, thumb_path, max_size=256):
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
Args:
src_path: chemin de l'image source (AVIF/WebP).
thumb_path: chemin de sortie JPEG.
max_size: taille maximale (côté le plus grand) en pixels.
Returns:
True si OK, False si échec.
"""
try:
from PIL import Image as PILImage
except ImportError:
logger.warning("PIL indisponible — impossible de générer les vignettes")
return False
try:
img = PILImage.open(str(src_path))
img = img.convert('RGB')
w, h = img.size
scale = min(1.0, max_size / max(w, h))
if scale < 1.0:
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
try:
resample = PILImage.Resampling.LANCZOS
except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
img = img.resize(new_size, resample)
Path(thumb_path).parent.mkdir(parents=True, exist_ok=True)
img.save(str(thumb_path), format='JPEG', quality=80)
return True
except Exception as e:
logger.debug(f"Vignette ignorée {src_path}: {e}")
return False
def _pick_display_viz(viz_keys):
"""Choisit la visualisation par défaut pour une tuile.
Privilégie hillshade_multi, sinon la première disponible selon l'ordre de repli.
"""
for v in _VIZ_FALLBACK_ORDER:
if v in viz_keys:
return v
return sorted(viz_keys)[0]
def _subdivision_k(resolution, tile_m=1000, target_px=2500):
"""Facteur de découpage k (grille k×k) pour alléger le rendu carte.
Au 0,2 m/px une dalle de 1 km fait 5000×5000 px (~100 Mo décodés dans le
navigateur) : on la découpe en quadrants de 500 m (k=2, 2500×2500 px).
À 0,5 m/px (2000 px) la dalle reste entière (k=1).
"""
px = max(1, int(round(tile_m / resolution)))
return max(1, int(round(px / target_px)))
def _subtile_corners(corners, i, j, k):
"""Coins WGS84 [SW, SE, NE, NW] de la sous-tuile (i, j) d'un découpage k×k.
i : indice vers l'est (0..k-1), j : indice vers le nord (0..k-1).
Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté
est quasi un parallélogramme à cette échelle (erreur écran < 1 px).
"""
sw, se, ne, nw = corners
def lerp(p, q, u):
return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]
def at(u, v):
return lerp(lerp(sw, se, u), lerp(nw, ne, u), v)
u0, u1 = i / k, (i + 1) / k
v0, v1 = j / k, (j + 1) / k
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
"""Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive.
Ne découpe que les visualisations proposées dans le panneau de couches. Retourne
la liste des entrées display (une par sous-tuile), ou None si le découpage
n'est pas nécessaire/possible (la dalle entière sera alors affichée).
"""
k = _subdivision_k(tile['resolution'])
if k <= 1:
return None
try:
from PIL import Image as PILImage
except ImportError:
return None
sub_dir = output_dir / sub_dir_name
sub_dir.mkdir(parents=True, exist_ok=True)
entries = {}
for j in range(k):
for i in range(k):
corners = _subtile_corners(tile['corners'], i, j, k)
entries[(i, j)] = {
'col': tile['col'], 'row': tile['row'],
'name': tile['name'], 'dir_name': tile['dir_name'],
'resolution': tile['resolution'],
'bounds': [[min(c[0] for c in corners), min(c[1] for c in corners)],
[max(c[0] for c in corners), max(c[1] for c in corners)]],
'corners': corners,
'display_viz': tile['display_viz'],
'viz': {},
'meta': tile.get('meta'),
'sub_i': i, 'sub_j': j, 'sub_k': k,
'size_km': round(1.0 / k, 3),
}
try:
resample = getattr(PILImage, 'LANCZOS', 1)
for viz_key in offered_viz_keys:
info = tile['viz'].get(viz_key)
if not info:
continue
stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}"
for key in entries}
# Régénère si au moins un fichier manque ou est périmé (dalle
# source recalculée depuis — comme les vignettes)
src = output_dir / info['full']
src_mtime = _mtime(src)
all_exist = all(
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime)
for stem in stems.values())
if not all_exist:
logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} "
f"({len(stems)} découpages)")
try:
img = PILImage.open(str(src))
img.load()
except Exception as e:
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
return None
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
W, H = img.size
for (i, j), stem in stems.items():
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
left, right = round(W * i / k), round(W * (i + 1) / k)
top = round(H * (1 - (j + 1) / k))
bottom = round(H * (1 - j / k))
quad = img.crop((left, top, right, bottom))
try:
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
format='AVIF', quality=_SUBTILE_AVIF_QUALITY,
speed=_SUBTILE_AVIF_SPEED)
except Exception as e:
logger.warning(f"Encodage AVIF impossible ({stem}), "
f"sous-tuilage abandonné : {e}")
return None
# Supprime l'ancien crop .webp d'une génération précédente
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
scale = min(1.0, 256 / max(quad.size))
if scale < 1.0:
quad = quad.resize((max(1, int(quad.size[0] * scale)),
max(1, int(quad.size[1] * scale))), resample)
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
format='WEBP', quality=80)
for (i, j), stem in stems.items():
entries[(i, j)]['viz'][viz_key] = {
'thumb': f"{sub_dir_name}/{stem}_thumb.webp",
'full': f"{sub_dir_name}/{stem}.avif",
}
except Exception as e:
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
return None
usable = [e for e in entries.values() if e['viz']]
if not usable:
return None
# Repli dalle entière pour les visualisations non découpées : elles restent
# superposables en couche (images plus lourdes, mais fonctionnelles).
sub_keys = set(offered_viz_keys)
for entry in usable:
for viz_key, info in tile['viz'].items():
if viz_key not in sub_keys:
entry['viz'][viz_key] = dict(info)
return usable
def _viz_src_dir(tile, info):
"""Dossier réel du fichier d'une visualisation.
Les couches fusionnées d'une autre résolution (run interrompu entre les
deux passes) vivent dans leur dossier d'origine — info['dir_name'] — et
non dans dir_path de la dalle affichée.
"""
d = info.get('dir_name') or tile.get('dir_name')
if d and d != Path(tile['dir_path']).name:
return Path(tile['dir_path']).parent / d
return Path(tile['dir_path'])
def _collect_tile_metadata(tile, dtm_dir):
"""Rassemble les métadonnées de génération d'une tuile.
Lit la méthode de classification du sol depuis le sidecar DTM
(output/DTM/{basename}_dtm{suffix}_method.txt, écrit par pipeline.py),
et les dates/tailles des fichiers de visualisation.
Returns:
{method: str|None, generated: str|None,
viz: {viz_key: {date: str, size: int}}}
"""
meta = {'method': None, 'generated': None, 'viz': {}}
suffix = _res_suffix_str(tile['resolution'])
method_file = Path(dtm_dir) / f"{tile['basename']}_dtm{suffix}_method.txt"
if not method_file.exists() and suffix:
# La classification du sol est partagée entre résolutions : repli sur
# le sidecar de la résolution primaire si le spécifique manque.
method_file = Path(dtm_dir) / f"{tile['basename']}_dtm_method.txt"
try:
if method_file.exists():
method = method_file.read_text(encoding='utf-8').strip()
if method:
meta['method'] = method
# Le sidecar est écrit juste après la création du DTM :
# sa date ≈ date de génération de la dalle.
meta['generated'] = datetime.fromtimestamp(
method_file.stat().st_mtime).strftime('%Y-%m-%d %H:%M')
except OSError as e:
logger.debug(f"Métadonnées illisibles {method_file.name}: {e}")
for viz_key, info in tile['viz'].items():
try:
viz_dir = _viz_src_dir(tile, info)
st = (viz_dir / info['filename']).stat()
meta['viz'][viz_key] = {
'date': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'),
'size': st.st_size,
}
except OSError:
continue
if meta['generated'] is None and meta['viz']:
dates = [v['date'] for v in meta['viz'].values()]
meta['generated'] = min(dates)
return meta
def build_index(output_dir, output_format='avif'):
"""Génère la carte interactive HTML des tuiles traitées, organisée par zones.
Scanne output_dir/visualisations/, collecte les métadonnées de génération,
génère les vignettes JPEG, écrit les assets (CSS/JS) puis
output_dir/index.html.
Args:
output_dir: dossier de sortie racine (contient visualisations/).
output_format: format des images ('avif' ou 'webp') — pour info.
Returns:
Path vers index.html si succès, None si échec ou aucune tuile.
"""
output_dir = Path(output_dir)
vis_dir = output_dir / 'visualisations'
dtm_dir = output_dir / 'DTM'
t_start = time.time()
tiles = scan_tiles(vis_dir)
if not tiles:
logger.info("Aucune tuile traitée trouvée — index global non généré")
return None
# Bounds GPS par tuile (géoréférencement exact pour la carte Leaflet)
attach_gps_bounds(tiles)
# Une seule tuile par position (col, row) : on garde la résolution la plus
# fine disponible. Sinon les versions 0,5 m et 0,2 m d'une même dalle se
# superposent exactement sur la carte et celle ajoutée en dernier dans le
# DOM (la moins résolue, tri croissant) masque l'autre.
best_by_pos = {}
tiles_by_pos = {}
for t in tiles:
key = (t['col'], t['row'])
tiles_by_pos.setdefault(key, []).append(t)
if key not in best_by_pos or t['resolution'] < best_by_pos[key]['resolution']:
best_by_pos[key] = t
# Complète chaque dalle affichée (résolution la plus fine) avec les
# visualisations produites uniquement à l'autre résolution : sans cela,
# une couche en cours de génération (passe 0,5 m faite, 0,2 m pas encore)
# reste invisible sur la carte et absente du panneau de couches.
# dir_name mémorise le dossier d'origine : vignettes, URLs et métadonnées
# doivent lire le fichier là où il existe réellement.
for key, best in best_by_pos.items():
for other in tiles_by_pos[key]:
if other is best:
continue
for viz_key, viz_info in other['viz'].items():
if viz_key not in best['viz']:
best['viz'][viz_key] = dict(viz_info, dir_name=other['dir_name'])
tiles = sorted(best_by_pos.values(),
key=lambda t: (t['resolution'], -t['row'], t['col']))
# Détecter les zones géographiques (pour info uniquement)
zones = compute_zones(tiles)
logger.info(f" {len(zones)} zone(s) détectée(s)")
thumb_dir = output_dir / 'index_thumbs'
thumb_dir.mkdir(parents=True, exist_ok=True)
# Collecte toutes les visualisations disponibles (pour le panneau de couches).
all_viz_keys = set()
for t in tiles:
all_viz_keys.update(t['viz'].keys())
# Restreint le découpage en sous-tuiles aux visualisations choisies
if _CARTO_SUBTILED_VIZ:
sub_viz = [v for v in _CARTO_SUBTILED_VIZ if v in all_viz_keys]
if sub_viz:
logger.info(f" Sous-tuilage limité à : {', '.join(sub_viz)}")
else:
sub_viz = list(all_viz_keys)
# Génère les vignettes et construit les données pour le HTML.
zone_records = []
thumbs_generated = 0
thumbs_failed = 0
tile_idx = 0
n_tiles = len(tiles)
logger.info(f" Vignettes : {n_tiles} tuile(s) × {len(all_viz_keys)} visualisation(s)")
for zone in zones:
zone_tile_records = []
for t in zone['tiles']:
tile_idx += 1
viz_thumbs = {}
regen = 0
for viz_key, info in t['viz'].items():
# Les couches fusionnées d'une autre résolution vivent dans
# leur dossier d'origine (info['dir_name']), pas dir_path.
src = _viz_src_dir(t, info) / info['filename']
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
thumb_path = thumb_dir / thumb_name
# Régénère si manquante ou périmée (tuile recalculée depuis)
if not _cached_file_fresh(thumb_path, _mtime(src)):
if generate_thumbnail(src, thumb_path):
thumbs_generated += 1
regen += 1
else:
thumbs_failed += 1
continue
else:
thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
}
if regen:
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
f"{regen} vignette(s) régénérée(s)")
if not viz_thumbs:
continue
display_viz = _pick_display_viz(viz_thumbs.keys())
tile_meta = _collect_tile_metadata(t, dtm_dir)
zone_tile_records.append({
'col': t['col'],
'row': t['row'],
'name': t['basename'],
'dir_name': t['dir_name'],
'resolution': t['resolution'],
'bounds': t.get('bounds'),
'corners': t.get('corners'),
'display_viz': display_viz,
'viz': viz_thumbs,
'meta': tile_meta,
})
if zone_tile_records:
zone_records.append({
'label': zone['label'],
'tiles': zone_tile_records,
'bbox': zone['bbox'],
})
if not zone_records:
logger.warning("Aucune vignette générée — index global abandonné")
return None
# Calculer le bbox global (pour la stat bar)
global_bbox = compute_bbox(tiles)
# HTML avec données intégrées : liste plate des quads affichables.
# Les dalles 0,2 m (5000×5000 px) sont découpées en sous-tuiles 500 m
# (quadrants 2500×2500 px) pour alléger mémoire et chargements navigateur.
sub_dir_name = 'index_subtiles'
display_tiles = []
n_dalles = 0
n_sous = 0
for zr in zone_records:
for t in zr['tiles']:
n_dalles += 1
subs = _build_subtiles(t, sub_viz, output_dir, sub_dir_name)
if subs:
display_tiles.extend(subs)
n_sous += len(subs)
else:
display_tiles.append(t)
if n_sous:
logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)")
# Export des données de carte (index_tiles.json) : la webapp les sert via
# /api/tiles pour afficher les tuiles terminées en direct, sans recharger
# la page — le pipeline le réécrit après chaque tuile en mode incrémental.
ordered_viz, default_viz = _panel_selection(all_viz_keys)
(output_dir / 'index_tiles.json').write_text(json.dumps({
'tiles': display_tiles,
'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz},
'stats': {'default_viz': default_viz, 'n_tiles': len(display_tiles)},
}, ensure_ascii=False), encoding='utf-8')
_write_assets(output_dir)
html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format)
html_path = output_dir / 'index.html'
html_path.write_text(html, encoding='utf-8')
logger.info(f"Index global généré : {html_path} ({time.time() - t_start:.1f}s)")
logger.info(f" {len(tiles)} tuile(s) • {thumbs_generated} vignette(s) générée(s)"
+ (f" • {thumbs_failed} échec(s)" if thumbs_failed else ""))
logger.info(f" Grille : {global_bbox['min_col']}-{global_bbox['max_col']} km E × "
f"{global_bbox['min_row']}-{global_bbox['max_row']} km N")
return html_path
def _write_assets(output_dir):
"""Écrit les fichiers statiques de l'interface (CSS + JS) dans output/assets/."""
assets_dir = Path(output_dir) / 'assets'
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / 'app.css').write_text(_APP_CSS, encoding='utf-8')
(assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8')
def _panel_selection(all_viz_keys):
"""Couches proposées dans le panneau + couche activée par défaut.
Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit
décrire le panneau exactement comme la page au moment du rebuild).
"""
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
# les autres visualisations existent sur disque mais ne sont pas proposées.
panel_keys = set(all_viz_keys)
if PANEL_VIZ is not None:
panel_keys &= set(PANEL_VIZ)
# Ordre des couches dans le panneau (selon ordre préféré puis alpha).
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in panel_keys]
for v in sorted(panel_keys):
if v not in ordered_viz:
ordered_viz.append(v)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
return ordered_viz, default_viz
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
ordered_viz, default_viz = _panel_selection(all_viz_keys)
tiles_json = json.dumps(tiles, ensure_ascii=False)
# Options du sélecteur de génération/régénération : uniquement les couches
# réellement proposées dans le panneau de visualisation (mêmes couches,
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Repli sur
# aspect si aucune couche n'est encore disponible.
gen_keys = ordered_viz if ordered_viz else ['aspect']
gen_viz_options = "\n".join(
f' <option value="{KEYWORD_TO_STEP.get(v, v)}"'
f'{" selected" if v == "aspect" else ""}>'
f'{VIZ_LABELS.get(v, v)}</option>'
for v in gen_keys)
viz_meta_json = json.dumps({
k: {'label': VIZ_LABELS.get(k, k)}
for k in ordered_viz
}, ensure_ascii=False)
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
ensure_ascii=False)
n_tiles = len(tiles)
grid_w = global_bbox['max_col'] - global_bbox['min_col'] + 1
grid_h = global_bbox['max_row'] - global_bbox['min_row'] + 1
return _HTML_TEMPLATE.format(
tiles_json=tiles_json,
viz_meta_json=viz_meta_json,
stats_json=stats_json,
gen_viz_options=gen_viz_options,
n_tiles=n_tiles,
grid_w=grid_w,
grid_h=grid_h,
output_format=output_format.upper(),
)
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte LiDAR</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="assets/app.css"/>
</head>
<body>
<div id="viewport"></div>
<header id="brand" class="panel">
<h1>Carte LiDAR</h1>
<span class="stat" id="statTiles">{n_tiles} tuile(s) · {grid_w}×{grid_h} km · {output_format}</span>
</header>
<nav id="mapTools" class="panel">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
</nav>
<aside id="layerPanel" class="panel">
<div class="panel-head">
<span class="panel-title">Couches</span>
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
<button id="layerCollapse" class="icon-btn" title="Replier / déplier">▾</button>
</div>
<div id="layerBody">
<div class="layer-row" id="osmRow">
<button class="eye on" id="osmToggle" title="Afficher / masquer le fond de carte"></button>
<span class="layer-name">Fond de carte OSM</span>
<input type="range" id="osmOpacity" min="0" max="100" step="1" title="Opacité du fond">
<span class="layer-pct" id="osmPct"></span>
</div>
<div class="layer-sub">Visualisations · glisser pour réordonner</div>
<div id="vizLayers"></div>
</div>
</aside>
<aside id="infoPanel" class="panel" hidden>
<div class="panel-head">
<span class="panel-title" id="infoTitle">Tuile</span>
<button id="infoClose" class="icon-btn" title="Fermer">×</button>
</div>
<div id="infoBody"></div>
</aside>
<div id="zoomIndicator" class="chip">Zoom</div>
<div id="hint" class="chip">
Molette : zoom · Clic-glisser : déplacer<br>
Clic sur tuile : infos de génération
</div>
<!-- Barre de confirmation de génération de zone -->
<div id="genBar" class="panel">
<span id="genMsg"></span>
<label class="gen-opt">
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
</label>
<label class="gen-opt" title="Ctrl+clic pour sélectionner plusieurs visualisations">
Visualisations :
<select id="genViz" class="gen-select" multiple size="4">
{gen_viz_options}
</select>
</label>
<label class="gen-opt">
Classification :
<select id="genClass" class="gen-select">
<option value="auto">Auto (détection)</option>
<option value="ign" selected>IGN (pré-classification)</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select>
<select id="genIgnClasses" class="gen-select" title="Classes LAS extraites pour le MNT (méthode IGN, mode pur)">
<option value="sol" selected>Sol (2)</option>
<option value="sol,unclassified">Sol + non-classé (2+1)</option>
<option value="sol,unclassified,eau,virtuel">Sol+non-classé+eau+virtuel (2+1+9+66)</option>
</select>
</label>
<label class="gen-opt" title="Ramène le MNT au retour le plus bas de chaque cellule (sol nu)">
<input type="checkbox" id="genBareEarth"> Sol nu
</label>
<button id="genGo" class="primary">Lancer</button>
<button id="genCancel">Annuler</button>
</div>
<!-- File de génération : panneau latéral (le journal n'est plus au 1er plan) -->
<aside id="genQueue" class="panel" hidden>
<div class="panel-head">
<span class="panel-title">File de génération</span>
<div class="gen-queue-actions">
<span id="genQueueCount" class="gen-queue-count"></span>
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div>
</div>
<div id="genQueueBody"></div>
</aside>
<script>
const TILES = {tiles_json};
const VIZ_META = {viz_meta_json};
const STATS = {stats_json};
</script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="assets/app.js"></script>
</body>
</html>
"""
_APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */
:root {
--bg: #0b0e13;
--panel: rgba(16, 20, 27, 0.9);
--panel-border: rgba(255, 255, 255, 0.09);
--text: #d9dee6;
--muted: #8b94a3;
--accent: #5aa9e6;
--accent-dim: #2e7cc4;
--danger: #e94560;
--success: #4cc98a;
--success-dim: #2f8f63;
--radius: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
font-size: 13px;
}
#viewport { position: absolute; inset: 0; background: var(--bg); }
.leaflet-container { background: var(--bg); font: inherit; }
/* Fond OSM assombri pour faire ressortir les tuiles LiDAR */
#viewport .leaflet-tile-pane { filter: grayscale(0.6) brightness(0.5) contrast(1.2); }
#viewport.bright .leaflet-tile-pane { filter: none; }
/* --- Panneaux flottants --- */
.panel {
position: fixed; z-index: 900;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: var(--radius);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
color: var(--text);
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--panel-border);
}
.panel-title { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
button {
background: rgba(255, 255, 255, 0.06); color: var(--text);
border: 1px solid var(--panel-border); border-radius: 6px;
padding: 6px 12px; font-size: 13px; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
button.primary { background: var(--accent-dim); border-color: var(--accent-dim); }
button.primary:hover { background: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.icon-btn { padding: 2px 8px; font-size: 14px; line-height: 1.2; }
select, input[type="checkbox"] { accent-color: var(--accent); }
.gen-select {
background: rgba(255, 255, 255, 0.06); color: var(--text);
border: 1px solid var(--panel-border); border-radius: 4px;
padding: 3px 6px; font-size: 12px; cursor: pointer;
}
/* --- En-tête (marque + stats) --- */
#brand { top: 12px; left: 12px; padding: 8px 14px; }
#brand h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; }
#brand .stat { font-size: 11.5px; color: var(--muted); }
/* --- Outils carte (haut droite) --- */
#mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; }
#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; }
#mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); }
#mapTools #btnGenZone:hover { background: var(--accent); }
#mapTools #btnComplete {
width: auto; padding: 0 14px; font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em;
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
box-shadow: 0 2px 10px rgba(76, 201, 138, 0.25);
}
#mapTools #btnComplete:hover { background: var(--success); border-color: var(--success); box-shadow: 0 2px 14px rgba(76, 201, 138, 0.45); }
#mapTools #btnComplete .ok-ic { margin-right: 6px; font-weight: 700; }
/* --- Panneau de couches (gauche) --- */
#layerPanel { top: 74px; left: 12px; width: 300px; }
#layerPanel.collapsed #layerBody { display: none; }
#layerPanel.collapsed #layerCollapse { transform: rotate(-90deg); }
#layerCollapse { border: none; background: none; color: var(--muted); }
#layerBody { padding: 6px; max-height: calc(100vh - 200px); overflow-y: auto; }
.layer-row {
display: grid; grid-template-columns: 26px 1fr 74px 36px;
align-items: center; gap: 6px;
padding: 5px 6px; border-radius: 6px;
}
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
.layer-row.dragging { opacity: 0.45; }
.layer-row[data-layer] { cursor: grab; }
.eye {
width: 24px; height: 24px; padding: 0; border: none; background: none;
display: flex; align-items: center; justify-content: center;
color: var(--muted);
}
.eye.on { color: var(--accent); }
.eye svg { width: 16px; height: 16px; }
.layer-name {
font-size: 12.5px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; color: var(--muted);
}
.layer-row.on .layer-name { color: var(--text); }
.layer-pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.layer-sub {
font-size: 10.5px; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.05em; padding: 8px 6px 4px;
border-top: 1px solid var(--panel-border); margin-top: 4px;
}
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 3px; border-radius: 2px;
background: rgba(255, 255, 255, 0.15); outline: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 11px; height: 11px; border-radius: 50%;
background: var(--accent); border: none;
}
input[type="range"]::-moz-range-thumb {
width: 11px; height: 11px; border-radius: 50%;
background: var(--accent); border: none;
}
/* --- Tuiles LiDAR sur la carte --- */
.ltile-anchor { width: 0; height: 0; }
.ltile {
position: absolute; left: 0; top: 0; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.10);
/* Fond transparent obligatoire : l'opacité des couches doit laisser voir
les couches du dessous et le fond de carte, pas un rectangle noir. */
background: transparent;
}
.ltile img {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: fill; display: block;
/* Évite le drag natif des images qui avale le mouseup (dessin de zones) */
pointer-events: none; -webkit-user-drag: none; user-select: none;
}
.ltile:hover { border-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); }
.ltile.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); }
/* --- Panneau d'infos tuile (droite) --- */
#infoPanel { top: 60px; right: 12px; width: 330px; }
#infoBody { padding: 10px 12px; max-height: calc(100vh - 180px); overflow-y: auto; }
#infoClose { border: none; background: none; color: var(--muted); font-size: 18px; }
#infoClose:hover { color: var(--danger); }
.meta-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin-bottom: 10px; }
.meta-list dt { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px; }
.meta-list dd { font-size: 12.5px; }
.meta-list dd.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; }
.info-sub {
font-size: 10.5px; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.05em; margin: 10px 0 4px;
border-top: 1px solid var(--panel-border); padding-top: 8px;
}
.viz-row {
display: grid; grid-template-columns: 22px 1fr 88px 52px 22px;
align-items: center; gap: 4px; padding: 3px 2px; border-radius: 4px;
}
.viz-row:hover { background: rgba(255, 255, 255, 0.05); }
.viz-row .mini {
width: 20px; height: 20px; padding: 0; border: none; background: none;
display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 13px; text-decoration: none;
}
.viz-row .mini:hover { color: var(--accent); }
.viz-row .mini svg { width: 14px; height: 14px; }
.viz-name { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.viz-date { font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; }
.viz-size { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
#infoActions { margin-top: 12px; display: flex; gap: 8px; }
/* --- Puces d'état (bas d'écran) --- */
.chip {
position: fixed; z-index: 900;
background: var(--panel); border: 1px solid var(--panel-border);
border-radius: 6px; padding: 5px 10px;
font-size: 11px; color: var(--muted);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
#zoomIndicator { bottom: 12px; left: 12px; font-variant-numeric: tabular-nums; }
#hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; }
/* --- Génération de zones --- */
#genBar {
bottom: 48px; left: 50%; transform: translateX(-50%);
display: none; align-items: center; gap: 12px;
padding: 10px 14px; max-width: min(96vw, 900px); flex-wrap: wrap;
}
#genBar.show { display: flex; }
#genMsg { font-size: 12.5px; }
.gen-opt {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
}
/* File de génération : panneau latéral — peut occuper toute la hauteur
de l'écran si besoin (plafonné au viewport, scroll interne au-delà) */
#genQueue {
bottom: 48px; right: 12px; width: 780px; max-width: 94vw;
max-height: calc(100vh - 60px);
display: flex; flex-direction: column;
}
#genQueueBody {
padding: 8px 10px; flex: 1; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column; gap: 8px;
}
.gen-queue-actions { display: flex; align-items: center; gap: 8px; }
.gen-queue-count { font-size: 11px; color: var(--muted); }
.gen-task { border: 1px solid var(--panel-border); border-radius: 6px; }
.gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; }
.gen-task-dot {
width: 9px; height: 9px; border-radius: 50%; flex: none;
background: var(--muted);
}
.gen-task.running .gen-task-dot {
background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
}
.gen-task.done .gen-task-dot { background: #39d98a; }
.gen-task.failed .gen-task-dot { background: var(--danger); }
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.gen-task-label {
font-size: 12px; flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gen-task-state { font-size: 11px; white-space: nowrap; }
.gen-task.running .gen-task-state { color: var(--accent); }
.gen-task.done .gen-task-state { color: #39d98a; }
.gen-task.failed .gen-task-state { color: var(--danger); }
.gen-task-time {
font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums;
}
.gen-task-toggle { padding: 1px 6px; font-size: 11px; }
.gen-task-log {
margin: 0 8px 8px; max-height: 160px; overflow: auto;
background: #0d1017; border: 1px solid var(--panel-border); border-radius: 6px;
padding: 8px 10px; font: 10.5px/1.45 ui-monospace, Menlo, monospace;
color: #9fb3d9; white-space: pre-wrap;
}
/* Tuiles une par une : nom court + puces d'étapes (types de visualisation).
Sans plafond : c'est le corps du panneau qui scrolle si trop long. */
.gen-task-tiles {
margin: 0 8px 8px;
display: flex; flex-direction: column; gap: 4px;
}
.gen-tile {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--panel-border); border-radius: 6px;
padding: 4px 8px; font-size: 11.5px;
}
.gen-tile-name {
min-width: 78px; flex: none; font-variant-numeric: tabular-nums;
color: var(--text); cursor: default;
}
.gen-tile-steps { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
.gen-step {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 7px; border-radius: 999px;
background: rgba(255, 255, 255, 0.05); color: var(--muted);
font-size: 10.5px; white-space: nowrap;
}
.gen-step::before { content: '·'; font-weight: bold; }
.gen-step.running { color: var(--accent); background: rgba(90, 169, 230, 0.12); }
.gen-step.running::before { content: '⟳'; animation: gen-pulse 1.1s ease-in-out infinite; }
.gen-step.ok { color: #39d98a; background: rgba(57, 217, 138, 0.10); }
.gen-step.ok::before { content: '✓'; }
.gen-step.fail { color: var(--danger); background: rgba(233, 69, 96, 0.12); }
.gen-step.fail::before { content: '✗'; }
.gen-step.skip::before { content: '–'; }
.gen-tile-state { flex: none; font-size: 11px; }
.gen-tile.done .gen-tile-state { color: #39d98a; }
.gen-tile.failed .gen-tile-state { color: var(--danger); }
body.drawing, body.drawing #viewport { cursor: crosshair !important; }
/* --- Responsive --- */
@media (max-width: 720px) {
#layerPanel { width: min(280px, 62vw); }
#infoPanel { width: min(310px, 66vw); top: auto; bottom: 48px; }
#hint { display: none; }
}
"""
_APP_JS = r"""'use strict';
/* Carte LiDAR — couches superposables, infos tuiles, génération de zones. */
// --- Données embarquées par index.py ---
// TILES : tuiles affichables (corners WGS84, viz {thumb, full}, meta)
// VIZ_META : {clé: {label}} des visualisations disponibles
// STATS : {default_viz, n_tiles}
const viewport = document.getElementById('viewport');
if (typeof L === 'undefined') {
viewport.innerHTML =
'<div style="padding:24px;color:#e94560;font-family:sans-serif;font-size:14px">' +
"Erreur : Leaflet n'a pas pu être chargé (accès Internet requis pour ouvrir cette page en ligne)." +
'</div>';
} else {
// ============================================================
// Carte Leaflet : zoom, pan, fond OSM
// ============================================================
const map = L.map('viewport', {
zoomControl: false,
attributionControl: false,
minZoom: 4,
maxZoom: 19
});
window.map = map; // accès débogage / automatisations
window.TILES = TILES;
const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
}).addTo(map);
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
// ============================================================
// État des couches (persisté en localStorage)
// ============================================================
// order : pile de visualisations, index 0 = bas de pile.
const LS_KEY = 'lidarLayers_v1';
function defaultLayers() {
const order = Object.keys(VIZ_META);
const base = STATS.default_viz && VIZ_META[STATS.default_viz]
? STATS.default_viz : order[0];
const on = {}, opacity = {};
for (const k of order) { on[k] = (k === base); opacity[k] = 1; }
return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } };
}
function loadLayers() {
const def = defaultLayers();
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return def;
const saved = JSON.parse(raw);
const order = (Array.isArray(saved.order) ? saved.order : [])
.filter(k => VIZ_META[k]);
for (const k of Object.keys(VIZ_META)) {
if (!order.includes(k)) order.push(k);
}
const on = {}, opacity = {};
for (const k of order) {
const op = saved.opacity ? saved.opacity[k] : undefined;
on[k] = saved.on && saved.on[k] === true;
opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op)))
? 1 : Math.min(1, Math.max(0, Number(op)));
}
const osm = Object.assign({}, def.osm, saved.osm || {});
return { order, on, opacity, osm };
} catch (e) { return def; }
}
const state = { layers: loadLayers() };
function saveLayers() {
try { localStorage.setItem(LS_KEY, JSON.stringify(state.layers)); } catch (e) { /* privé */ }
}
function activeLayers() {
return state.layers.order.filter(k => state.layers.on[k] && VIZ_META[k]);
}
// ============================================================
// Marqueurs : une div par tuile, une <img> par couche active
// ============================================================
let gridBounds = null;
const markers = new Map();
const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0);
// Crée le marqueur Leaflet d'une tuile (utilisé à l'init et en direct,
// quand une tuile terminée apparaît en cours de génération).
function registerTile(t) {
if (!t.corners) return;
const center = [(t.corners[0][0] + t.corners[2][0]) / 2,
(t.corners[0][1] + t.corners[2][1]) / 2];
const icon = L.divIcon({
className: 'ltile-anchor',
html: '<div class="ltile"></div>',
iconSize: [0, 0]
});
const mk = L.marker(L.latLng(center[0], center[1]), {
icon: icon,
interactive: true,
keyboard: false
}).addTo(map);
mk.on('click', () => { if (!genMode) showTileInfo(t); });
markers.set(tileKey(t), mk);
}
function recomputeGridBounds() {
gridBounds = null;
for (const t of TILES) {
if (!t.corners) continue;
const b = L.latLngBounds(t.corners);
gridBounds = gridBounds ? gridBounds.extend(b) : b;
}
}
for (const t of TILES) registerTile(t);
recomputeGridBounds();
function tileDiv(t) {
const mk = markers.get(tileKey(t));
const el = mk && mk.getElement();
return el ? el.querySelector('.ltile') : null;
}
// Redimensionne + rote chaque tuile selon les coins projetés à l'échelle courante
function layoutTiles() {
// map.project() renvoie des pixels NON arrondis, contrairement à
// latLngToContainerPoint() (arrondi entier) : au zoom global un bord de tuile
// de 1 km ne fait que ~0,6 px et l'arrondi l'écrase à 0 (rotations fausses
// de 0/45/90°, largeurs 0 ou 1 px, tuiles qui ne s'emboîtent plus).
const zoom = map.getZoom();
for (const t of TILES) {
const div = tileDiv(t);
if (!div) continue;
const [sw, se, ne, nw] = t.corners.map(c => map.project(L.latLng(c[0], c[1]), zoom));
const w = Math.hypot(se.x - sw.x, se.y - sw.y);
const h = Math.hypot(nw.x - sw.x, nw.y - sw.y);
const ang = Math.atan2(se.y - sw.y, se.x - sw.x) * 180 / Math.PI;
// Compense l'arrondi entier de la position du marqueur (erreur <= 0,5 px)
// pour que le bord partagé de 2 tuiles voisines coïncide exactement.
const cx = (sw.x + ne.x) / 2, cy = (sw.y + ne.y) / 2;
div.style.marginLeft = (cx - Math.round(cx)) + 'px';
div.style.marginTop = (cy - Math.round(cy)) + 'px';
div.style.width = w + 'px';
div.style.height = h + 'px';
div.style.transform = 'translate(-50%, -50%) rotate(' + ang.toFixed(4) + 'deg)';
}
// Resynchronise aussi les <img> : au premier rendu les marqueurs viennent
// tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés.
syncTileLayers();
}
// Crée / supprime les <img> de chaque tuile selon les couches actives,
// applique opacité et ordre d'empilement (z-index).
function syncTileLayers() {
const act = activeLayers();
for (const t of TILES) {
const div = tileDiv(t);
if (!div) continue;
div.querySelectorAll('img[data-layer]').forEach(img => {
if (!act.includes(img.dataset.layer)) { img.src = ''; img.remove(); }
});
act.forEach((k, i) => {
if (!t.viz[k]) return;
let img = div.querySelector('img[data-layer="' + CSS.escape(k) + '"]');
if (!img) {
img = document.createElement('img');
img.dataset.layer = k;
img.alt = '';
div.appendChild(img);
}
img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]);
});
}
updateImages();
}
// Bascule vignette (256 px) ↔ image pleine résolution selon le zoom :
// en vue d'ensemble la vignette suffit ; quand une tuile dépasse ~300 px à
// l'écran ET est visible dans le viewport (marge de préchargement), on charge
// l'image complète pour conserver les détails. En dézoomant on retombe sur
// la vignette pour libérer la mémoire.
function updateImages() {
const vp = viewport.getBoundingClientRect();
const marge = 300;
const act = new Set(activeLayers());
for (const t of TILES) {
const div = tileDiv(t);
if (!div) continue;
const r = div.getBoundingClientRect();
const visible = r.right > vp.left - marge && r.left < vp.right + marge &&
r.bottom > vp.top - marge && r.top < vp.bottom + marge;
const big = visible && (r.width > 300 || r.height > 300);
for (const img of div.querySelectorAll('img[data-layer]')) {
const k = img.dataset.layer;
if (!act.has(k)) continue;
const v = t.viz[k];
if (!v) continue;
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
const want = (big && state.layers.opacity[k] > 0.15) ? v.full : v.thumb;
if (!img.src.endsWith(want)) img.src = want;
}
}
}
// ============================================================
// Panneau de couches
// ============================================================
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const vizLayersEl = document.getElementById('vizLayers');
let dragKey = null;
function renderOsmRow() {
const { on, opacity, dark } = state.layers.osm;
const toggle = document.getElementById('osmToggle');
const slider = document.getElementById('osmOpacity');
const pct = document.getElementById('osmPct');
toggle.classList.toggle('on', on);
toggle.innerHTML = on ? SVG_EYE : SVG_EYE_OFF;
slider.value = Math.round(opacity * 100);
pct.textContent = Math.round(opacity * 100) + '%';
if (on) { if (!map.hasLayer(osmLayer)) osmLayer.addTo(map); }
else { if (map.hasLayer(osmLayer)) map.removeLayer(osmLayer); }
osmLayer.setOpacity(opacity);
viewport.classList.toggle('bright', !dark);
}
function renderLayerPanel() {
vizLayersEl.innerHTML = '';
// Affichage haut de pile en premier (le tableau order est bas → haut).
const topFirst = state.layers.order.slice().reverse();
for (const key of topFirst) {
const meta = VIZ_META[key];
if (!meta) continue;
const row = document.createElement('div');
row.className = 'layer-row' + (state.layers.on[key] ? ' on' : '');
row.draggable = true;
row.dataset.layer = key;
row.title = meta.label;
row.innerHTML =
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<span class="layer-name">' + meta.label + '</span>' +
'<input type="range" min="0" max="100" step="1" value="' +
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
const eye = row.querySelector('.eye');
const range = row.querySelector('input[type="range"]');
const pct = row.querySelector('.layer-pct');
eye.addEventListener('click', () => {
state.layers.on[key] = !state.layers.on[key];
// Réactivation sur une opacité nulle : on remonte à 100 %
if (state.layers.on[key] && state.layers.opacity[key] === 0) state.layers.opacity[key] = 1;
saveLayers();
renderLayerPanel();
syncTileLayers();
});
range.addEventListener('input', () => {
const v = Number(range.value) / 100;
state.layers.opacity[key] = v;
pct.textContent = range.value + '%';
// Applique directement sans reconstruire le DOM
for (const t of TILES) {
const div = tileDiv(t);
const img = div && div.querySelector('img[data-layer="' + CSS.escape(key) + '"]');
if (img) img.style.opacity = String(v);
}
updateImages();
});
range.addEventListener('change', saveLayers);
row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); });
row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); });
row.addEventListener('dragover', e => e.preventDefault());
row.addEventListener('drop', e => {
e.preventDefault();
if (!dragKey || dragKey === key) return;
const ord = state.layers.order;
ord.splice(ord.indexOf(dragKey), 1);
ord.splice(ord.indexOf(key), 0, dragKey);
saveLayers();
renderLayerPanel();
syncTileLayers();
});
vizLayersEl.appendChild(row);
}
}
document.getElementById('osmToggle').addEventListener('click', () => {
state.layers.osm.on = !state.layers.osm.on;
saveLayers(); renderOsmRow();
});
document.getElementById('osmOpacity').addEventListener('input', e => {
state.layers.osm.opacity = Number(e.target.value) / 100;
osmLayer.setOpacity(state.layers.osm.opacity);
document.getElementById('osmPct').textContent = e.target.value + '%';
});
document.getElementById('osmOpacity').addEventListener('change', saveLayers);
// Clic sur le nom du fond = bascule mode sombre / clair
document.getElementById('osmRow').querySelector('.layer-name')
.addEventListener('click', () => {
state.layers.osm.dark = !state.layers.osm.dark;
saveLayers(); renderOsmRow();
});
document.getElementById('osmRow').querySelector('.layer-name').title =
'Cliquer : fond de carte clair / assombri';
const layerPanel = document.getElementById('layerPanel');
document.getElementById('layerCollapse').addEventListener('click', () => {
layerPanel.classList.toggle('collapsed');
try { localStorage.setItem('lidarLayersCollapsed', layerPanel.classList.contains('collapsed') ? '1' : ''); } catch (e) {}
});
try {
if (localStorage.getItem('lidarLayersCollapsed') === '1') layerPanel.classList.add('collapsed');
} catch (e) {}
// ============================================================
// Vue : zoom / pan / persistance (#zoom,lat,lng dans l'URL)
// ============================================================
function readViewHash() {
const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)$/.exec(location.hash);
if (m && !isNaN(parseFloat(m[1])) && !isNaN(parseFloat(m[2])) && !isNaN(parseFloat(m[3]))) {
return { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]) };
}
return null;
}
function saveViewHash() {
const c = map.getCenter();
history.replaceState(null, '', '#' +
map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6));
}
function fitView() {
// Si une vue a été sauvegardée dans l'URL (F5 / lien partagé), on la
// restaure au lieu de recadrer sur l'ensemble des tuiles.
const v = readViewHash();
if (v) { map.setView([v.lat, v.lng], v.zoom); return; }
// Fallback : vue sauvegardée en sessionStorage (après une génération).
const saved = sessionStorage.getItem('lidarMap');
if (saved) {
try {
const v = JSON.parse(saved);
if (v.lat != null && v.lng != null && v.zoom != null) {
map.setView([v.lat, v.lng], v.zoom);
return;
}
} catch (e) { /* stockage illisible : on retombe sur fitBounds */ }
}
if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] });
}
const zoomInd = document.getElementById('zoomIndicator');
document.getElementById('zoomIn').addEventListener('click', () => map.zoomIn(1));
document.getElementById('zoomOut').addEventListener('click', () => map.zoomOut(1));
document.getElementById('zoomReset').addEventListener('click', () => {
history.replaceState(null, '', location.pathname + location.search);
if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] });
});
map.on('zoomend', () => {
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
layoutTiles();
});
map.on('moveend', () => { updateImages(); saveViewHash(); });
map.on('click', hideTileInfo);
map.whenReady(() => layoutTiles());
// ============================================================
// Panneau d'infos tuile (métadonnées de génération)
// ============================================================
const infoPanel = document.getElementById('infoPanel');
const infoTitle = document.getElementById('infoTitle');
const infoBody = document.getElementById('infoBody');
let currentTile = null;
function methodLabel(m) {
if (!m) return 'inconnue';
if (m === 'ign') return 'IGN — sol (2)';
if (m.startsWith('ign_')) return 'IGN — classes ' + m.split('_').slice(1).join(', ');
return { smrf: 'SMRF', csf: 'CSF', auto: 'Auto (détection)' }[m] || m;
}
function humanSize(bytes) {
if (!Number.isFinite(bytes)) return '';
if (bytes < 1024) return bytes + ' o';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' Ko';
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
}
function layerPos(key) {
const i = state.layers.order.indexOf(key);
return i === -1 ? 999 : i;
}
function showTileInfo(t) {
currentTile = t;
const meta = t.meta || {};
const vizRows = Object.keys(t.viz)
.sort((a, b) => layerPos(a) - layerPos(b))
.map(k => {
const vm = meta.viz && meta.viz[k];
return '<div class="viz-row">' +
'<button class="mini" data-act="show" data-viz="' + k + '" title="Afficher cette couche">' + SVG_EYE + '</button>' +
'<span class="viz-name" title="' + (VIZ_META[k] ? VIZ_META[k].label : k) + '">' +
(VIZ_META[k] ? VIZ_META[k].label : k) + '</span>' +
'<span class="viz-date">' + (vm ? vm.date : '') + '</span>' +
'<span class="viz-size">' + (vm ? humanSize(vm.size) : '') + '</span>' +
'<a class="mini" href="' + t.viz[k].full + '" download title="Télécharger l\'image">⤓</a>' +
'</div>';
}).join('');
infoTitle.textContent = 'Tuile ' + t.col + ',' + t.row;
infoBody.innerHTML =
'<dl class="meta-list">' +
'<dt>Dalle</dt><dd class="mono">' + t.name + '</dd>' +
'<dt>Coordonnées L93</dt><dd class="mono">' + t.col + ' km E · ' + t.row + ' km N</dd>' +
'<dt>Résolution</dt><dd>' + t.resolution + ' m/px</dd>' +
'<dt>Taille</dt><dd>' + (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km</dd>' +
'<dt>Classification sol</dt><dd>' + methodLabel(meta.method) + '</dd>' +
'<dt>Générée le</dt><dd>' + (meta.generated || '?') + '</dd>' +
'</dl>' +
'<div class="info-sub">Visualisations</div>' +
vizRows +
'<div id="infoActions">' +
'<button id="infoRegen"' + (apiAvailable ? '' : ' disabled title="API indisponible — lancez ./run.sh --serve"') +
'>Régénérer cette tuile</button>' +
'</div>';
infoBody.querySelectorAll('button[data-act="show"]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.viz;
if (!VIZ_META[k]) return;
state.layers.on[k] = true;
if (state.layers.opacity[k] === 0) state.layers.opacity[k] = 1;
saveLayers();
renderLayerPanel();
syncTileLayers();
layerPanel.classList.remove('collapsed');
});
});
const regen = document.getElementById('infoRegen');
if (regen) regen.addEventListener('click', () => regenerateCurrentTile());
// Surlignage de la tuile sélectionnée
for (const tk of TILES) {
const div = tileDiv(tk);
if (div) div.classList.toggle('selected', tk === t);
}
infoPanel.hidden = false;
}
function hideTileInfo() {
infoPanel.hidden = true;
currentTile = null;
for (const t of TILES) {
const div = tileDiv(t);
if (div) div.classList.remove('selected');
}
}
document.getElementById('infoClose').addEventListener('click', hideTileInfo);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') hideTileInfo();
});
function regenerateCurrentTile() {
if (!currentTile || !apiAvailable) return;
const t = currentTile;
hideTileInfo();
pendingTiles = [[t.col, t.row]];
pendingRegen = true;
pendingCompleteAll = false;
document.getElementById('genRegen').checked = true;
genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez';
genGo.style.display = '';
genBar.classList.add('show');
}
// ============================================================
// Génération de nouvelles zones via l'API web (./run.sh --serve)
// ============================================================
let apiAvailable = false;
const genBtn = document.getElementById('btnGenZone');
const btnComplete = document.getElementById('btnComplete');
const genBar = document.getElementById('genBar');
const genMsg = document.getElementById('genMsg');
const genGo = document.getElementById('genGo');
const genCancel = document.getElementById('genCancel');
const genRegen = document.getElementById('genRegen');
const genClass = document.getElementById('genClass');
const genIgnClasses = document.getElementById('genIgnClasses');
const genViz = document.getElementById('genViz');
const genBareEarth = document.getElementById('genBareEarth');
const genQueue = document.getElementById('genQueue');
const genQueueBody = document.getElementById('genQueueBody');
const genQueueClear = document.getElementById('genQueueClear');
const genQueueCount = document.getElementById('genQueueCount');
let genMode = false, drawStart = null, drawRect = null;
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
const ghostCells = [];
fetch('/api/status').then(r => {
apiAvailable = r.ok;
if (r.ok) { genBtn.hidden = false; btnComplete.hidden = false; }
}).catch(() => {});
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
// Visualisations choisies dans la barre de génération (noms d'étapes --only).
// Sert au preview (détection des tuiles incomplètes) et au lancement : une
// tuile existante mais privée d'une de ces visualisations est incluse.
function selectedGenViz() {
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
return sel.length ? sel : ['aspect'];
}
function exitGenMode() {
genMode = false; drawStart = null;
document.body.classList.remove('drawing');
if (drawRect) { drawRect.remove(); drawRect = null; }
map.dragging.enable();
}
genBtn.addEventListener('click', () => {
if (genMode) { exitGenMode(); return; }
clearGhosts();
genBar.classList.remove('show');
genMode = true;
map.dragging.disable();
document.body.classList.add('drawing');
genMsg.textContent = 'Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)';
genGo.style.display = 'none';
genBar.classList.add('show');
});
map.on('mousedown', e => {
if (!genMode) return;
drawStart = e.latlng;
if (drawRect) drawRect.remove();
drawRect = L.rectangle([drawStart, drawStart],
{ color: '#4cc9f0', weight: 2, dashArray: '6 4', fillOpacity: 0.05 }).addTo(map);
});
map.on('mousemove', e => {
if (!genMode || !drawStart || !drawRect) return;
drawRect.setBounds(L.latLngBounds(drawStart, e.latlng));
});
map.on('mouseup', e => {
if (!genMode || !drawStart) return;
const b = L.latLngBounds(drawStart, e.latlng);
drawStart = null;
if (Math.abs(b.getWest() - b.getEast()) < 1e-9 || Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
exitGenMode();
genMsg.textContent = 'Analyse de la zone...';
genGo.style.display = 'none';
genBar.classList.add('show');
fetch('/api/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
regenerate: genRegen.checked,
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
if (drawRect) { drawRect.remove(); drawRect = null; }
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = false; // zone dessinée : le scope global est levé
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
));
if (!pendingTiles.length) {
genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' +
(genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)');
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à ' +
(genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') +
(d.capped ? ' (zone tronquée à 400)' : '');
genGo.style.display = '';
}).catch(() => { genMsg.textContent = 'API indisponible'; });
});
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
// moins une des visualisations choisies (pas de téléchargement) — la barre de
// confirmation reste utilisable (choix des couches, classification, Lancer).
btnComplete.addEventListener('click', () => {
if (!apiAvailable) return;
clearGhosts();
genBar.classList.add('show');
genMsg.textContent = 'Analyse des tuiles présentes dans input/...';
genGo.style.display = 'none';
fetch('/api/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all_missing: true, regenerate: genRegen.checked,
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = true;
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
));
if (!pendingTiles.length) {
genMsg.textContent = 'Aucune visualisation manquante pour les couches choisies';
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à compléter (dalles présentes dans input/)' +
(d.capped ? ' (liste tronquée)' : '');
genGo.style.display = '';
}).catch(() => { genMsg.textContent = 'API indisponible'; });
});
genGo.addEventListener('click', () => {
if (!pendingTiles.length) return;
genBar.classList.remove('show');
clearGhosts();
const nTiles = pendingTiles.length;
const isRegen = genRegen.checked || pendingRegen;
fetch('/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: pendingTiles,
regenerate: isRegen,
viz: selectedGenViz(),
all_missing: pendingCompleteAll,
ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
bare_earth: genBareEarth ? genBareEarth.checked : false })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
return startGenTask(nTiles + ' tuile(s) — ' + verb);
})
.catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); });
});
genCancel.addEventListener('click', () => {
genBar.classList.remove('show');
clearGhosts();
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
if (genMode) exitGenMode();
});
// ============================================================
// File de génération : panneau latéral persistant (sessionStorage).
// Les tâches survivent au rechargement de la carte : une tâche finie
// reste affichée « Terminé » jusqu'au vidage manuel de la liste.
// Chaque tâche montre ses tuiles une par une (nom court + types
// d'étapes) ; le journal brut reste accessible via le bouton ⌄.
// ============================================================
const GEN_QKEY = 'lidarGenQueue';
let genTasks = [];
try { genTasks = JSON.parse(sessionStorage.getItem(GEN_QKEY) || '[]'); } catch (e) { genTasks = []; }
// Migration des tâches créées avant l'affichage par tuiles : sans champ
// tiles, le journal brut pouvait rester ouvert d'office — on le replie.
genTasks.forEach(t => { if (!Array.isArray(t.tiles)) { t.tiles = []; t.logOpen = false; } });
let genPollTimer = null;
function saveGenTasks() {
try { sessionStorage.setItem(GEN_QKEY, JSON.stringify(genTasks)); } catch (e) { /* privé */ }
}
function genStepEl(s) {
const el = document.createElement('span');
el.className = 'gen-step ' + s.state;
el.textContent = s.label;
if (s.state === 'skip') el.title = 'Déjà existant — ignoré';
return el;
}
function genTileEl(t) {
const el = document.createElement('div');
el.className = 'gen-tile ' + t.state;
const name = document.createElement('span');
name.className = 'gen-tile-name';
name.textContent = t.short; name.title = t.name;
const steps = document.createElement('span');
steps.className = 'gen-tile-steps';
(t.steps || []).forEach(s => steps.appendChild(genStepEl(s)));
const state = document.createElement('span');
state.className = 'gen-tile-state';
state.textContent = t.state === 'done' ? '✓' : t.state === 'failed' ? '✗' : '';
el.append(name, steps, state);
return el;
}
function genTaskEl(t) {
const el = document.createElement('div');
el.className = 'gen-task ' + t.state;
const head = document.createElement('div');
head.className = 'gen-task-head';
const dot = document.createElement('span'); dot.className = 'gen-task-dot';
const label = document.createElement('span');
label.className = 'gen-task-label';
label.title = t.label; label.textContent = t.label;
const state = document.createElement('span'); state.className = 'gen-task-state';
const tiles = t.tiles || [];
if (t.state === 'running') {
const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length;
state.textContent = tiles.length
? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)'
: 'En cours…';
} else if (t.state === 'done') {
state.textContent = 'Terminé';
} else {
state.textContent = 'Échec (code ' + t.returncode + ')';
}
const time = document.createElement('span');
time.className = 'gen-task-time'; time.textContent = t.time;
const toggle = document.createElement('button');
toggle.className = 'icon-btn gen-task-toggle';
toggle.textContent = t.logOpen ? '⌃' : '⌄';
toggle.title = 'Afficher / masquer le journal brut';
head.append(dot, label, state, time, toggle);
el.appendChild(head);
// Corps : tuiles une par une (nom + types d'étapes), en cours et échecs
// en tête, terminées en dessous — le journal brut reste replié sous ⌄.
if (tiles.length) {
const list = document.createElement('div');
list.className = 'gen-task-tiles';
const rank = s => s === 'running' ? 0 : s === 'failed' ? 1 : s === 'done' ? 3 : 2;
[...tiles].sort((a, b) => rank(a.state) - rank(b.state))
.forEach(x => list.appendChild(genTileEl(x)));
el.appendChild(list);
}
const log = document.createElement('pre');
log.className = 'gen-task-log';
log.hidden = !t.logOpen;
log.textContent = (t.log || []).join('\n') || 'Démarrage...';
log.scrollTop = log.scrollHeight;
el.appendChild(log);
toggle.addEventListener('click', () => {
t.logOpen = !t.logOpen; saveGenTasks(); renderGenQueue();
});
return el;
}
function renderGenQueue() {
genQueueBody.textContent = '';
// Plus récentes en haut
for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t));
const running = genTasks.filter(t => t.state === 'running').length;
genQueueCount.textContent = genTasks.length
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
: '';
genQueue.hidden = genTasks.length === 0;
}
function startGenTask(labelText) {
const now = new Date();
genTasks.push({
id: Date.now(), label: labelText,
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
state: 'running', returncode: null, log: [], logOpen: false, tiles: [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
}
function finishRunningTasks(d) {
let changed = false;
for (const t of genTasks) {
if (t.state !== 'running') continue;
t.state = d.returncode === 0 ? 'done' : 'failed';
t.returncode = d.returncode;
changed = true;
}
if (changed) saveGenTasks();
renderGenQueue();
}
// ============================================================
// Tuiles en direct : chaque tuile terminée apparaît sans recharger
// ============================================================
// Pendant un run, le pipeline régénère l'index après chaque tuile terminée
// (--incremental-index) et la webapp sert ces données via /api/tiles. On
// fusionne les nouveautés dans la carte : la tuile est visible dès la fin
// de son rendu, sans attendre la fin de la file.
let liveTilesStamp = null;
function mergeTilesData(data) {
if (!data || !Array.isArray(data.tiles)) return;
const byKey = new Map();
for (const t of data.tiles) {
if (t.corners) byKey.set(tileKey(t), t);
}
let changed = false;
// Retire les tuiles disparues (ex. dalle 0,5 m remplacée par ses
// sous-tuiles 0,2 m plus fines)
for (let i = TILES.length - 1; i >= 0; i--) {
const key = tileKey(TILES[i]);
if (byKey.has(key)) continue;
const mk = markers.get(key);
if (mk) { mk.remove(); markers.delete(key); }
TILES.splice(i, 1);
changed = true;
}
byKey.forEach((t, key) => {
const cur = TILES.find(x => tileKey(x) === key);
if (!cur) {
TILES.push(t);
registerTile(t);
changed = true;
} else if (cur !== t) {
Object.assign(cur, t); // tuile enrichie (nouvelles couches en cours de run)
changed = true;
}
});
// Couches nouvellement proposées : ajoutées au panneau, éteintes par défaut
let metaChanged = false;
if (data.viz_meta) {
for (const k of Object.keys(data.viz_meta)) {
if (VIZ_META[k]) continue;
VIZ_META[k] = data.viz_meta[k];
if (!state.layers.order.includes(k)) {
state.layers.order.push(k);
state.layers.on[k] = false;
state.layers.opacity[k] = 1;
}
metaChanged = true;
}
}
if (changed) {
recomputeGridBounds();
const statTiles = document.getElementById('statTiles');
if (statTiles) {
const cols = [...new Set(TILES.map(t => t.col))].sort((a, b) => a - b);
const rows = [...new Set(TILES.map(t => t.row))].sort((a, b) => a - b);
const gw = cols.length ? cols[cols.length - 1] - cols[0] + 1 : 0;
const gh = rows.length ? rows[rows.length - 1] - rows[0] + 1 : 0;
statTiles.textContent = TILES.length + ' tuile(s) · ' + gw + '×' + gh + ' km · ' +
statTiles.textContent.split('·').pop().trim();
}
}
if (changed || metaChanged) renderLayerPanel();
if (changed) { syncTileLayers(); layoutTiles(); }
}
function pollLiveTiles() {
const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : '';
fetch('/api/tiles' + q).then(r => r.ok ? r.json() : null).then(d => {
if (!d) return;
liveTilesStamp = d.stamp;
if (d.tiles) mergeTilesData(d);
}).catch(() => {});
}
function pollGenStatus() {
if (genPollTimer) return; // un seul sondage global pour toute la file
genPollTimer = setInterval(() => {
fetch('/api/status').then(r => r.json()).then(d => {
// Réponse d'erreur (ex: machine de traitement injoignable) : ignorer,
// le sondage reprendra — ne pas marquer les tâches en échec.
if (typeof d.running !== 'boolean') return;
const running = genTasks.filter(t => t.state === 'running');
if (!running.length) {
clearInterval(genPollTimer); genPollTimer = null;
return;
}
const lines = d.log || [];
for (const t of running) {
t.log = lines.slice(-60);
// Tuiles une par une (nom court + types d'étapes) depuis /api/status
if (d.tiles && d.tiles.length) t.tiles = d.tiles;
}
if (!d.running) {
clearInterval(genPollTimer); genPollTimer = null;
finishRunningTasks(d);
// Sauvegarde la vue courante pour qu'elle survive au rechargement
// (sinon fitView() recadre sur l'ensemble des tuiles et le zoom part à zéro).
try {
const c = map.getCenter();
sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
} catch (e) { /* sessionStorage indisponible : pas grave */ }
syncThenReload();
} else {
saveGenTasks(); renderGenQueue();
pollLiveTiles();
}
}).catch(() => {});
}, 2000);
}
// Après un run : ramener les tuiles produites par la machine de traitement
// (rsync via /api/sync — mode webapp sur Raspberry Pi), reconstruire vignettes
// et carte, puis recharger. Sans backend distant, /api/sync se réduit à un
// rebuild local : même flux partout. Rechargement de secours après 30 min.
function syncThenReload() {
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
const t0 = Date.now();
const check = setInterval(() => {
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) {
clearInterval(check);
location.reload();
}
}).catch(() => { clearInterval(check); location.reload(); });
}, 2000);
}).catch(() => location.reload());
}
genQueueClear.addEventListener('click', () => {
genTasks = genTasks.filter(t => t.state === 'running');
saveGenTasks(); renderGenQueue();
});
renderGenQueue();
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
// adopte une génération lancée hors interface (API directe), ou réaffiche le
// dernier run terminé après un redémarrage du serveur (état rechargé du
// fichier .generation.job.json). La clé `started` évite les doublons au
// rechargement : un même run n'est adopté qu'une fois.
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
if (!s || genTasks.some(t => t.started === s.started)) return;
const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
if (s.running) {
genTasks.push({
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
time: now, started: s.started,
state: 'running', returncode: null, log: [], logOpen: false, tiles: s.tiles || [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
} else if (s.returncode !== null && s.tiles && s.tiles.length) {
genTasks.push({
id: Date.now(), label: 'Dernière génération',
time: now, started: s.started,
state: s.returncode === 0 ? 'done' : 'failed', returncode: s.returncode,
log: [], logOpen: false, tiles: s.tiles,
});
saveGenTasks(); renderGenQueue();
}
}).catch(() => {});
// ============================================================
// Initialisation
// ============================================================
renderOsmRow();
renderLayerPanel();
syncTileLayers();
fitView();
layoutTiles();
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
// ============================================================
// Détection de nouvelles couches (run de génération en cours)
// ============================================================
// Le panneau et les URLs par tuile sont figés au rebuild de l'index :
// /api/layers liste ce qui existe sur disque, et le bouton ↻ resynchronise
// puis reconstruit la carte (vignettes + sous-tuiles) et recharge la page.
const layerRefreshBtn = document.getElementById('layerRefresh');
function checkNewLayers() {
fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => {
if (!d || !layerRefreshBtn) return;
const news = Object.keys(d).filter(k => !VIZ_META[k]);
layerRefreshBtn.hidden = news.length === 0;
if (news.length) {
layerRefreshBtn.title = 'Nouvelles couches : ' +
news.map(k => d[k]).join(', ') + ' — cliquer pour actualiser la carte';
}
}).catch(() => {});
}
checkNewLayers();
setInterval(checkNewLayers, 60000);
if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
layerRefreshBtn.textContent = '…';
layerRefreshBtn.disabled = true;
fetch('/api/sync', { method: 'POST' })
.then(r => r.ok ? r.json() : Promise.reject('rejeté'))
.then(() => new Promise(resolve => {
const poll = setInterval(() => {
fetch('/api/sync').then(r => r.json()).then(s => {
if (!s.running) { clearInterval(poll); resolve(s.error); }
}).catch(() => { clearInterval(poll); resolve(null); });
}, 3000);
}))
.then(err => {
if (err) {
layerRefreshBtn.textContent = '↻';
layerRefreshBtn.disabled = false;
layerRefreshBtn.title = 'Erreur synchronisation/rebuild : ' + err;
} else {
location.reload();
}
})
.catch(() => {
layerRefreshBtn.textContent = '↻';
layerRefreshBtn.disabled = false;
});
});
} // fin guard Leaflet
"""