Files
lidar_rendu/lidar_pipeline/index.py
Antoine Jacquin 23969c9e14 Add Leaflet interactive map, tile generator compose, auto-sync cache, deploy docs
index.py rewritten as a continuous Leaflet map: rotated L93 tiles, stackable
visualization layers (per-layer opacity, drag-reorder persisted in
localStorage), tile info panel, live rebuild after each tile during a run.
Leaflet is vendored in assets/vendor/ so the map works fully offline;
georeferencing falls back rasterio -> pyproj -> affine so the lightweight
webapp (no GDAL) is supported.

docker-compose.worker.yml adds the tile generator service (full image + GPU)
that remote webapps call via LIDAR_GENERATION_URL, plus a one-shot process
profile. webapp.py gains LIDAR_AUTO_SYNC_SECONDS periodic cache refresh and
LIDAR_REGEN_CIDR restricting generation to the local network. run.sh
--serve-webapp now mounts ~/.ssh read-only so the rsync sync works.

docs/DEPLOY_WEBAPP.md completed for Raspberry Pi deployment: prerequisites,
git clone install, SSH key setup, first sync, update procedure and
troubleshooting.
2026-09-04 21:36:14 +02:00

2660 lines
105 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
# Vignette intermédiaire (px) : pallier entre la vignette 256 px et l'image
# pleine résolution, pour éviter d'étirer la vignette ou de décoder l'AVIF
# complet dès qu'une tuile dépasse ~300 px à l'écran.
_MID_THUMB_SIZE = 640
# 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, mid_path=None,
mid_size=640):
"""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.
mid_path: vignette intermédiaire optionnelle (JPEG, taille mid_size).
mid_size: taille maximale de la vignette intermédiaire.
Returns:
True si la vignette principale est 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:
try:
resample = PILImage.Resampling.LANCZOS
except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
def resized(source, target):
scale = min(1.0, target / max(source.size))
if scale >= 1.0:
return source
return source.resize((max(1, int(source.size[0] * scale)),
max(1, int(source.size[1] * scale))), resample)
img = PILImage.open(str(src_path))
img = img.convert('RGB')
Path(thumb_path).parent.mkdir(parents=True, exist_ok=True)
if mid_path is not None:
try:
resized(img, mid_size).save(str(mid_path), format='JPEG', quality=82)
except Exception as e:
logger.debug(f"Vignette intermédiaire ignorée {src_path}: {e}")
resized(img, max_size).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)
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_mid.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)
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
mid_img = quad
if mid_scale < 1.0:
mid_img = quad.resize((max(1, int(quad.size[0] * mid_scale)),
max(1, int(quad.size[1] * mid_scale))), resample)
mid_img.save(str(output_dir / sub_dir_name / (stem + '_mid.webp')),
format='WEBP', quality=82)
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",
'mid': f"{sub_dir_name}/{stem}_mid.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']
src_mtime = _mtime(src)
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
thumb_path = thumb_dir / thumb_name
mid_name = f"{t['dir_name']}_{viz_key}_mid.jpg"
mid_path = thumb_dir / mid_name
# Régénère si manquante ou périmée (tuile recalculée depuis)
if (not _cached_file_fresh(thumb_path, src_mtime)
or not _cached_file_fresh(mid_path, src_mtime)):
if generate_thumbnail(src, thumb_path, mid_path=mid_path,
mid_size=_MID_THUMB_SIZE):
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 mid_path.is_file():
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}"
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/.
Inclut Leaflet vendorisé (assets/vendor/leaflet) : la carte reste
fonctionnelle hors ligne, sans dépendance au CDN unpkg.
"""
import shutil
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')
src_vendor = Path(__file__).parent / 'assets' / 'vendor'
dst_vendor = assets_dir / 'vendor'
if src_vendor.is_dir():
shutil.rmtree(dst_vendor, ignore_errors=True)
shutil.copytree(src_vendor, dst_vendor)
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). Toutes
# pré-sélectionnées : une régénération refait la dalle complète du panneau,
# l'utilisateur pouvant désélectionner pour cibler. 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)}" selected>'
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="assets/vendor/leaflet/leaflet.css"/>
<link rel="stylesheet" href="assets/app.css"/>
</head>
<body>
<div id="viewport"></div>
<!-- Barre latérale unique : marque + onglets Couches / Génération -->
<aside id="sidebar" class="panel">
<div class="sidebar-head">
<div class="head-row">
<h1>Carte LiDAR</h1>
<button id="sidebarCollapse" class="icon-btn" title="Replier / déplier le panneau">◀</button>
</div>
<span class="stat" id="statTiles">{n_tiles} tuile(s) · {grid_w}×{grid_h} km · {output_format}</span>
</div>
<div class="sidebar-body">
<div class="tabbar">
<button class="tab active" data-tab="layers">Couches</button>
<button class="tab" data-tab="gen">Génération<span id="genBadge" class="tab-badge" hidden></span></button>
<button class="tab" data-tab="tile">Tuile</button>
</div>
<div class="tab-page" id="tab-layers">
<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">
<span>Visualisations · glisser ⠿ pour réordonner</span>
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
</div>
<div id="vizLayers"></div>
</div>
<div class="tab-page" id="tab-gen" hidden>
<!-- Actions Zone / Compléter : réservées à cet onglet -->
<div class="gen-actions">
<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>
</div>
<!-- Confirmation de génération (options + lancement) -->
<div id="genBar">
<div id="genMsg"></div>
<label class="gen-opt">
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
</label>
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
<span>Visualisations :</span>
<select id="genViz" class="gen-select" multiple size="4">
{gen_viz_options}
</select>
</label>
<label class="gen-opt col">
<span>Classification :</span>
<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>
<div class="gen-run">
<button id="genGo" class="primary">Lancer</button>
<button id="genCancel">Annuler</button>
</div>
</div>
<!-- File de génération : dans l'onglet, plus en surimpression -->
<div id="genQueue" hidden>
<div class="queue-head">
<span class="panel-title">File de génération</span>
<span id="genQueueCount"></span>
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div>
<div id="genQueueBody"></div>
</div>
</div>
<div class="tab-page" id="tab-tile" hidden>
<div id="tileBody">
<div class="tile-empty">Cliquez sur une tuile de la carte pour afficher ses informations de génération.</div>
</div>
</div>
</div>
</aside>
<nav id="mapTools" class="panel">
<button id="btnMenu" title="Afficher / masquer le panneau">☰</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>
<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>
<script>
const TILES = {tiles_json};
const VIZ_META = {viz_meta_json};
const STATS = {stats_json};
</script>
<script src="assets/vendor/leaflet/leaflet.js"></script>
<script src="assets/app.js"></script>
</body>
</html>
"""
_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */
:root {
--bg: #0b0e13;
--panel: rgba(16, 20, 27, 0.93);
--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;
--sbw: 320px; /* largeur de la barre latérale */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
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;
}
/* Décalage des contrôles ancrés à gauche (échelle Leaflet, puce zoom)
selon la présence de la barre latérale. */
body { --left-ui: calc(var(--sbw) + 24px); }
body.sb-collapsed { --left-ui: 12px; }
#viewport { position: absolute; inset: 0; background: var(--bg); }
.leaflet-container { background: var(--bg); font: inherit; }
/* Fond OSM assombri pour faire ressortir les tuiles LiDAR : simple opacité
sur le tile-pane (un filter CSS forcerait sa rastérisation complète à
chaque frame de pan/zoom — coûteux) */
#viewport .leaflet-tile-pane { opacity: 0.45; }
#viewport.bright .leaflet-tile-pane { opacity: 1; }
#viewport .leaflet-bottom.leaflet-left { left: var(--left-ui); }
/* --- Panneaux : socle visuel commun (unité de l'interface) --- */
.panel {
position: fixed; z-index: 900;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: var(--radius);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
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: 6px;
padding: 3px 6px; font-size: 12px; cursor: pointer;
}
/* --- Barre latérale (gauche, pleine hauteur) --- */
#sidebar {
top: 12px; bottom: 12px; left: 12px; width: var(--sbw);
display: flex; flex-direction: column;
}
.sidebar-head { padding: 10px 12px 8px; border-bottom: 1px solid var(--panel-border); }
.head-row { display: flex; align-items: center; justify-content: space-between; }
#sidebar h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; }
#sidebar .stat { display: block; margin-top: 2px; font-size: 11px; color: var(--muted); }
#sidebarCollapse { color: var(--muted); border: none; background: none; }
#sidebarCollapse:hover { color: var(--text); }
#sidebar.collapsed { bottom: auto; }
#sidebar.collapsed .sidebar-body { display: none; }
#sidebar.collapsed #sidebarCollapse { transform: rotate(180deg); }
.sidebar-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* Onglets : contrôle segmenté */
.tabbar {
display: flex; gap: 4px; margin: 8px; padding: 3px;
background: rgba(255, 255, 255, 0.05); border-radius: 8px;
}
.tab {
flex: 1; border: none; background: transparent; border-radius: 6px;
padding: 6px 8px; font-size: 12px; font-weight: 600; color: var(--muted);
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.tab:hover { background: rgba(255, 255, 255, 0.07); color: var(--text); border-color: transparent; }
.tab.active { background: var(--accent-dim); color: #fff; }
.tab-badge {
min-width: 16px; padding: 0 4px; border-radius: 999px;
background: var(--accent); color: #06263f;
font-size: 10px; font-weight: 700; line-height: 15px;
}
.tab-page { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 8px 10px; }
/* --- Lignes de couches --- */
.layer-row {
display: grid; grid-template-columns: 16px 24px minmax(0, 1fr) 80px 30px;
align-items: center; gap: 6px;
padding: 5px 6px; border-radius: 6px;
user-select: none;
}
#osmRow { grid-template-columns: 24px minmax(0, 1fr) 80px 30px; }
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
.layer-row.dragging { opacity: 0.45; }
.layer-grip {
color: var(--muted); cursor: grab; font-size: 13px;
text-align: center; line-height: 1;
}
.layer-grip:hover, .layer-row.dragging .layer-grip { color: var(--accent); }
.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 {
display: flex; align-items: center; justify-content: space-between;
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: 6px;
}
/* Sliders d'opacité : piste fine dans une zone cliquable haute de 18 px —
le pouce reste saisissable au drag (l'élément lui-même fait toute la
hauteur, pas seulement la piste de 4 px). */
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 18px; background: transparent;
outline: none; cursor: pointer; margin: 0;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.18);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 13px; height: 13px; border-radius: 50%;
background: var(--accent); margin-top: -4.5px;
border: 2px solid #0b0e13;
box-shadow: 0 0 0 1px rgba(90, 169, 230, 0.4);
}
input[type="range"]:hover::-webkit-slider-thumb { background: #7ec4f5; }
input[type="range"]::-moz-range-track {
height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.18);
}
input[type="range"]::-moz-range-thumb {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent); border: 2px solid #0b0e13;
}
/* --- 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;
/* Layout et peinture des tuiles hors écran skippés par le navigateur */
content-visibility: auto;
}
/* Uniquement pendant l'animation de zoom (classe posée par app.js) :
scaling sur le GPU, puis libération pour ne pas immobiliser de mémoire. */
body.zooming .ltile { will-change: transform; }
.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); }
/* --- Fiche tuile (onglet Tuile) --- */
.tile-info { padding: 6px 6px 10px; }
.tile-info-head { display: flex; align-items: center; gap: 8px; }
.tile-info-title { font-size: 13px; font-weight: 700; }
.tile-info-badge {
font-size: 10.5px; color: var(--accent); background: rgba(90, 169, 230, 0.12);
border-radius: 999px; padding: 1px 8px; font-variant-numeric: tabular-nums;
}
.tile-info-close { margin-left: auto; color: var(--muted); border: none; background: none; font-size: 16px; }
.tile-info-close:hover { color: var(--danger); }
.tile-empty {
padding: 20px 12px; color: var(--muted); font-size: 12px;
line-height: 1.5; text-align: center;
}
.tile-info-dalle {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 10.5px; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 3px;
}
.tile-info-meta { font-size: 11.5px; color: var(--muted); margin-top: 4px; }
.tile-info-sub {
font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;
margin: 8px 0 6px; border-top: 1px solid var(--panel-border); padding-top: 8px;
}
/* Puces de couches : œil + nom + téléchargement, repliées sur 2 colonnes */
.vpills { display: flex; flex-wrap: wrap; gap: 5px; }
.vpill {
display: inline-flex; align-items: center; gap: 3px;
padding: 2px 4px 2px 7px; border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
}
.vpill.on { background: rgba(90, 169, 230, 0.14); }
.vpill-eye, .vpill-name {
border: none; background: none; padding: 0; cursor: pointer; color: var(--muted);
}
.vpill-eye {
display: inline-flex; width: 16px; height: 16px;
align-items: center; justify-content: center;
}
.vpill-eye svg { width: 13px; height: 13px; }
.vpill.on .vpill-eye { color: var(--accent); }
.vpill-name { font-size: 11.5px; color: var(--text); white-space: nowrap; }
.vpill-name:hover { color: var(--accent); }
.vpill-dl {
color: var(--muted); text-decoration: none; font-size: 12px;
padding: 0 4px; border-radius: 4px;
}
.vpill-dl:hover { color: var(--accent); }
.tile-info-actions { margin-top: 10px; }
.tile-info-actions button { width: 100%; font-size: 12px; padding: 6px 10px; }
/* --- 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; }
#btnMenu { display: none; }
/* --- 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);
}
#zoomIndicator { bottom: 12px; left: var(--left-ui); font-variant-numeric: tabular-nums; }
#hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; }
/* --- Onglet Génération --- */
/* Actions Zone / Compléter : propres à l'onglet, pleine largeur */
.gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.gen-actions button { width: 100%; font-size: 12.5px; }
#btnGenZone { background: var(--accent-dim); border-color: var(--accent-dim); }
#btnGenZone:hover { background: var(--accent); border-color: var(--accent); }
#btnComplete {
font-weight: 600; letter-spacing: 0.02em;
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
}
#btnComplete:hover { background: var(--success); border-color: var(--success); }
#btnComplete .ok-ic { margin-right: 6px; font-weight: 700; }
#genBar {
display: none; flex-direction: column; gap: 8px;
margin-top: 8px; padding: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--panel-border); border-radius: 8px;
}
#genBar.show { display: flex; }
#genMsg { font-size: 12.5px; line-height: 1.45; }
.gen-opt {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
font-size: 12px; color: var(--text); cursor: pointer;
}
.gen-opt.col { flex-direction: column; align-items: stretch; gap: 4px; }
.gen-opt.col > span { color: var(--muted); font-size: 11px; }
.gen-opt select { flex: 1; min-width: 0; }
.gen-run { display: flex; gap: 8px; margin-top: 2px; }
.gen-run #genGo { flex: 1; }
/* File de génération : section de l'onglet, largeur de la barre latérale */
#genQueue { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; }
.queue-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 6px; }
#genQueueCount { margin-left: auto; font-size: 11px; color: var(--muted); }
#genQueueBody {
display: flex; flex-direction: column; gap: 8px;
}
.gen-task { border: 1px solid var(--panel-border); border-radius: 6px; }
.gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; flex-wrap: wrap; }
.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: 120px;
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: 140px; 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) */
.gen-task-tiles {
margin: 0 8px 8px;
display: flex; flex-direction: column; gap: 4px;
}
.gen-tile {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
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); }
/* Barres de défilement fines et discrètes */
.tab-page::-webkit-scrollbar,
.gen-task-log::-webkit-scrollbar { width: 8px; }
.tab-page::-webkit-scrollbar-thumb,
.gen-task-log::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14); border-radius: 4px;
}
.tab-page::-webkit-scrollbar-track,
.gen-task-log::-webkit-scrollbar-track { background: transparent; }
body.drawing, body.drawing #viewport { cursor: crosshair !important; }
/* --- Responsive : la barre latérale devient un tiroir --- */
@media (max-width: 720px) {
body { --left-ui: 12px; }
#sidebar {
width: min(320px, 88vw); z-index: 1200;
transform: translateX(calc(-100% - 20px));
transition: transform 0.25s ease;
}
body.menu-open #sidebar { transform: none; }
#btnMenu { display: inline-block; }
#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
});
// Pane dédié au dessin de zones : les tuiles LiDAR sont des marqueurs
// (pane 600) et les vecteurs par défaut vivent en dessous (pane 400) ;
// on place rectangle et cellules fantômes au-dessus (620), sans capter
// les clics (pointer-events: none → la carte reste manipulable).
map.createPane('genDraw');
map.getPane('genDraw').style.zIndex = 620;
map.getPane('genDraw').style.pointerEvents = 'none';
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,
keepBuffer: 1
}).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.
// Chaque entrée met en cache div + <img> par couche + projections :
// les parcours déclenchés par moveend/zoomend ne font ni querySelector
// ni lecture de layout (getBoundingClientRect).
// ============================================================
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); });
const el = mk.getElement();
markers.set(tileKey(t), {
mk: mk, t: t,
div: el ? el.querySelector('.ltile') : null,
imgs: {},
latlngs: t.corners.map(c => L.latLng(c[0], c[1])),
bounds: L.latLngBounds(t.corners),
proj: null, projZoom: NaN
});
}
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 entryDiv(entry) {
if (!entry.div) {
const el = entry.mk.getElement();
entry.div = el ? el.querySelector('.ltile') : null;
}
return entry.div;
}
// Taille projetée (px) d'une tuile au zoom courant, mise en cache par zoom.
function tileSize(entry) {
const zoom = map.getZoom();
if (entry.projZoom === zoom && entry.proj) return entry.proj;
const c = entry.latlngs;
const sw = map.project(c[0], zoom), se = map.project(c[1], zoom),
nw = map.project(c[3], zoom);
entry.proj = {
w: Math.hypot(se.x - sw.x, se.y - sw.y),
h: Math.hypot(nw.x - sw.x, nw.y - sw.y)
};
entry.projZoom = zoom;
return entry.proj;
}
// 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 entry of markers.values()) {
const div = entryDiv(entry);
if (!div) continue;
const c = entry.latlngs;
const sw = map.project(c[0], zoom), se = map.project(c[1], zoom),
ne = map.project(c[2], zoom), nw = map.project(c[3], 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)';
entry.proj = { w: w, h: h };
entry.projZoom = zoom;
}
// 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 entry of markers.values()) {
const div = entryDiv(entry);
if (!div) continue;
for (const k of Object.keys(entry.imgs)) {
if (act.includes(k)) continue;
entry.imgs[k].src = '';
entry.imgs[k].remove();
delete entry.imgs[k];
}
act.forEach((k, i) => {
if (!entry.t.viz[k]) return;
let img = entry.imgs[k];
if (!img) {
img = document.createElement('img');
img.dataset.layer = k;
img.alt = '';
img.decoding = 'async';
div.appendChild(img);
entry.imgs[k] = img;
}
img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]);
});
}
updateImages();
}
// Bascule vignette (256 px) ↔ vignette intermédiaire (640 px) ↔ image
// pleine résolution selon le zoom : en vue d'ensemble la vignette suffit ;
// au-delà de ~900 px à l'écran on charge l'image complète pour conserver
// les détails, l'intermédiaire faisant la transition. En dézoomant on
// retombe sur la vignette pour libérer la mémoire. Visibilité et tailles
// sont calculées en coordonnées projetées : aucun reflow.
const PRELOAD_MARGIN_PX = 300;
const FULL_RES_PX = 900;
function updateImages() {
if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire
const zoom = map.getZoom();
const b = map.getBounds();
const swP = map.project(b.getSouthWest(), zoom);
const neP = map.project(b.getNorthEast(), zoom);
const visBounds = L.latLngBounds(
map.unproject(L.point(swP.x - PRELOAD_MARGIN_PX, swP.y + PRELOAD_MARGIN_PX), zoom),
map.unproject(L.point(neP.x + PRELOAD_MARGIN_PX, neP.y - PRELOAD_MARGIN_PX), zoom));
for (const entry of markers.values()) {
const visible = visBounds.intersects(entry.bounds);
const p = tileSize(entry);
const big = visible && (p.w > 300 || p.h > 300);
for (const k in entry.imgs) {
const v = entry.t.viz[k];
if (!v) continue;
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
let want = v.thumb;
if (big && state.layers.opacity[k] > 0.15) {
want = (v.mid && Math.max(p.w, p.h) < FULL_RES_PX) ? v.mid : v.full;
}
const img = entry.imgs[k];
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' : '');
// Le glisser-déposer ne part que de la poignée ⠿ : sinon le drag natif
// de la ligne avale le mousedown des sliders (opacité non manipulable
// à la souris) et les clics sur les boutons.
row.draggable = false;
row.dataset.layer = key;
row.addEventListener('mousedown', e => {
row.draggable = !!(e.target.closest && e.target.closest('.layer-grip'));
});
row.title = meta.label;
row.innerHTML =
'<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' +
'<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 entry of markers.values()) {
const img = entry.imgs[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.draggable = false; });
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';
// Barre latérale : repli + onglets (Couches / Génération)
const sidebar = document.getElementById('sidebar');
function setSidebarCollapsed(collapsed) {
sidebar.classList.toggle('collapsed', collapsed);
document.body.classList.toggle('sb-collapsed', collapsed);
try { localStorage.setItem('lidarSidebarCollapsed', collapsed ? '1' : ''); } catch (e) {}
}
document.getElementById('sidebarCollapse').addEventListener('click', () =>
setSidebarCollapsed(!sidebar.classList.contains('collapsed')));
try {
if (localStorage.getItem('lidarSidebarCollapsed') === '1') setSidebarCollapsed(true);
} catch (e) {}
const tabButtons = document.querySelectorAll('.tabbar .tab');
let activeTab = 'layers';
function switchTab(name) {
activeTab = name;
for (const b of tabButtons) {
b.classList.toggle('active', b.dataset.tab === name);
}
document.querySelectorAll('.tab-page').forEach(p => { p.hidden = p.id !== 'tab-' + name; });
// L'onglet Tuile est contextuel : sa sélection n'est pas mémorisée
if (name === 'layers' || name === 'gen') {
try { localStorage.setItem('lidarTab', name); } catch (e) {}
}
}
for (const b of tabButtons) b.addEventListener('click', () => switchTab(b.dataset.tab));
try {
const savedTab = localStorage.getItem('lidarTab');
if (savedTab === 'layers' || savedTab === 'gen') switchTab(savedTab);
} catch (e) {}
document.getElementById('btnMenu').addEventListener('click', () =>
document.body.classList.toggle('menu-open'));
// ============================================================
// 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] });
});
// will-change pendant l'animation de zoom uniquement : le scaling passe sur
// le GPU, et les couches sont libérées juste après (mémoire vidéo).
let zoomGpuTimer = null;
map.on('zoomstart', () => {
clearTimeout(zoomGpuTimer);
document.body.classList.add('zooming');
});
map.on('zoomend', () => {
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
layoutTiles();
zoomGpuTimer = setTimeout(() => document.body.classList.remove('zooming'), 1200);
});
// L'URL (#zoom,lat,lng) est mise à jour en débouncé : un replaceState à chaque
// moveend coûte plus cher qu'il n'y paraît.
let viewHashTimer = null;
function saveViewHashSoon() {
clearTimeout(viewHashTimer);
viewHashTimer = setTimeout(saveViewHash, 400);
}
window.addEventListener('pagehide', () => { clearTimeout(viewHashTimer); saveViewHash(); });
map.on('moveend', () => { updateImages(); saveViewHashSoon(); });
map.on('click', hideTileInfo);
map.whenReady(() => layoutTiles());
// ============================================================
// Fiche tuile (onglet Tuile) : métadonnées + couches de la tuile
// ============================================================
const tileBody = document.getElementById('tileBody');
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 fmtShortDate(s) {
return (s && s.length >= 16)
? s.slice(8, 10) + '/' + s.slice(5, 7) + ' ' + s.slice(11, 16)
: (s || '');
}
function regenDisabledAttr() {
if (!apiAvailable) return ' disabled title="API indisponible — lancez ./run.sh --serve"';
if (!regenAllowed) return ' disabled title="Génération réservée au réseau local"';
return '';
}
function tileInfoContent(t) {
const meta = t.meta || {};
const res = String(t.resolution).replace('.', ',');
const el = document.createElement('div');
el.className = 'tile-info';
el.innerHTML =
'<div class="tile-info-head">' +
'<span class="tile-info-title">Tuile ' + t.col + ' · ' + t.row + '</span>' +
'<span class="tile-info-badge">' + res + ' m/px</span>' +
'<button class="tile-info-close" title="Fermer">×</button>' +
'</div>' +
'<div class="tile-info-dalle" title="' + t.name + '">' + t.name + '</div>' +
'<div class="tile-info-meta">' +
(t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km · ' + methodLabel(meta.method) +
(meta.generated ? ' · généré le ' + fmtShortDate(meta.generated) : '') +
'</div>' +
'<div class="tile-info-sub">Visualisations</div>' +
'<div class="vpills"></div>' +
'<div class="tile-info-actions">' +
'<button class="tile-info-regen"' + regenDisabledAttr() +
'>Régénérer cette tuile</button>' +
'</div>';
const pills = el.querySelector('.vpills');
Object.keys(t.viz)
.sort((a, b) => layerPos(a) - layerPos(b))
.forEach(k => {
const vm = meta.viz && meta.viz[k];
const label = VIZ_META[k] ? VIZ_META[k].label : k;
const tip = vm ? [label, vm.date, vm.size != null ? humanSize(vm.size) : '']
.filter(Boolean).join(' — ') : label;
const pill = document.createElement('span');
pill.className = 'vpill' + (state.layers.on[k] ? ' on' : '');
pill.title = tip;
pill.innerHTML =
'<button class="vpill-eye" data-viz="' + k + '" title="Afficher / masquer cette couche">' +
(state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<button class="vpill-name" data-viz="' + k + '" title="Afficher / masquer cette couche">' + label + '</button>' +
'<a class="vpill-dl" href="' + t.viz[k].full + '" download title="Télécharger l\'image">⤓</a>';
pills.appendChild(pill);
});
// Œil ou nom : bascule de la couche, reflet immédiat dans la puce
el.querySelectorAll('button[data-viz]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.viz;
if (!VIZ_META[k]) return;
state.layers.on[k] = !state.layers.on[k];
if (state.layers.on[k] && state.layers.opacity[k] === 0) state.layers.opacity[k] = 1;
saveLayers();
renderLayerPanel();
syncTileLayers();
const pill = btn.closest('.vpill');
if (pill) {
pill.classList.toggle('on', state.layers.on[k]);
const eye = pill.querySelector('.vpill-eye');
if (eye) eye.innerHTML = state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF;
}
});
});
const regen = el.querySelector('.tile-info-regen');
if (regen) regen.addEventListener('click', () => regenerateCurrentTile());
const closeBtn = el.querySelector('.tile-info-close');
if (closeBtn) closeBtn.addEventListener('click', hideTileInfo);
return el;
}
function showTileInfo(t) {
currentTile = t;
// Surlignage de la tuile sélectionnée
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (div) div.classList.toggle('selected', entry.t === t);
}
tileBody.replaceChildren(tileInfoContent(t));
switchTab('tile');
}
function hideTileInfo() {
currentTile = null;
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (div) div.classList.remove('selected');
}
const empty = document.createElement('div');
empty.className = 'tile-empty';
empty.textContent = 'Cliquez sur une tuile de la carte pour afficher ses informations de génération.';
tileBody.replaceChildren(empty);
if (activeTab === 'tile') switchTab('layers');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') hideTileInfo();
});
function regenerateCurrentTile() {
if (!currentTile || !apiAvailable || !regenAllowed) return;
const t = currentTile;
hideTileInfo();
pendingTiles = [[t.col, t.row]];
pendingRegen = true;
pendingCompleteAll = false;
document.getElementById('genRegen').checked = true;
genGo.style.display = '';
showGenBar('1 tuile à régénérer — choisissez les options puis lancez');
}
// ============================================================
// Génération de nouvelles zones via l'API web (./run.sh --serve)
// ============================================================
let apiAvailable = false;
let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR)
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 genBadge = document.getElementById('genBadge');
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 = [];
// Affiche la barre de confirmation de génération, dans l'onglet Génération
// (bascule automatique sur cet onglet).
function showGenBar(msg) {
if (msg !== undefined) genMsg.textContent = msg;
switchTab('gen');
genBar.classList.add('show');
}
fetch('/api/status').then(r => {
apiAvailable = r.ok;
return r.ok ? r.json() : null;
}).then(d => {
if (!d) { if (apiAvailable) { genBtn.hidden = false; btnComplete.hidden = false; } return; }
// Génération réservée au réseau local (LIDAR_REGEN_CIDR, cf. webapp.py) :
// les boutons de traitement restent masqués pour les autres clients.
regenAllowed = d.regen_allowed !== false;
if (apiAvailable) {
genBtn.hidden = !regenAllowed;
btnComplete.hidden = !regenAllowed;
}
}).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');
genGo.style.display = 'none';
showGenBar('Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)');
});
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, pane: 'genDraw' }).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();
genGo.style.display = 'none';
showGenBar('Analyse de la zone...');
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, pane: 'genDraw' }).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(() => { showGenBar('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();
genGo.style.display = 'none';
showGenBar('Analyse des tuiles présentes dans input/...');
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, pane: 'genDraw' }).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(() => { showGenBar('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 => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
});
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() {
// Ne reconstruit le DOM que si les tâches ont changé : le sondage de 2 s
// ne doit pas faire vibrer la liste ni consommer du CPU pour rien.
const sig = JSON.stringify(genTasks);
if (sig === renderGenQueue.sig) { genQueue.hidden = genTasks.length === 0; return; }
renderGenQueue.sig = sig;
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;
genBadge.hidden = running === 0;
genBadge.textContent = running > 0 ? String(running) : '';
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);
}
const curByKey = new Map(TILES.map(t => [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 entry = markers.get(key);
if (entry) { entry.mk.remove(); markers.delete(key); }
TILES.splice(i, 1);
changed = true;
}
byKey.forEach((t, key) => {
const cur = curByKey.get(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(() => {
if (document.hidden) return; // onglet en arrière-plan : reprend au retour
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();
fitView();
syncTileLayers();
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(() => { if (!document.hidden) 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
"""