- Web map: multi-select picker in the generation bar (aspect, slope, positive openness, anisotropic openness, wavelet) passed to the API; the layer panel is restricted to the same shortlist (PANEL_VIZ) and a refresh button rebuilds the index when new layers appear on disk; jobs started outside the UI are now adopted into the visible queue - Uniform colors across tiles: openness/anisotropic/sailore now store local z-scores, and all renderers use fixed ranges (0-3 sigma, SVF 0-1 physical, slope 0-30 deg) instead of per-tile percentile stretches - Ray-tracing falls back to CPU when VRAM is exhausted so openness and SVF no longer fail silently on shared GPUs - build_index merges visualizations available at only one resolution into the displayed tile so in-progress layers stay visible - 11 new tests (142 passing)
1980 lines
76 KiB
Python
1980 lines
76 KiB
Python
"""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',
|
||
'aniso_open': 'Openness anisotropique',
|
||
'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'
|
||
|
||
# Couches proposées dans le panneau de couches et le sélecteur de génération
|
||
# (liste restreinte : base aspect + visualisations demandées). Les autres
|
||
# visualisations restent calculées et stockées mais ne sont pas proposées.
|
||
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
|
||
|
||
# Visualisations proposées dans la barre de génération de zones (noms
|
||
# d'étapes --only du pipeline) ; 'aspect' reste le choix par défaut (léger).
|
||
GEN_VIZ_CHOICES = [
|
||
('aspect', 'Aspect'),
|
||
('slope', 'Pente'),
|
||
('pos_open', 'Openness positive'),
|
||
('aniso_open', 'Openness anisotropique'),
|
||
('wavelet', 'Ondelette'),
|
||
]
|
||
|
||
# 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',
|
||
'aniso_open', 'wavelet')
|
||
|
||
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
|
||
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
|
||
# speed 0 (lent/meilleur) → 10 (rapide) ; 6+8 quasi identique en taille,
|
||
# on garde 8 (2× plus rapide) pour 2500×2500 px.
|
||
_SUBTILE_AVIF_QUALITY = 55
|
||
_SUBTILE_AVIF_SPEED = 8
|
||
|
||
# Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli.
|
||
_VIZ_FALLBACK_ORDER = [
|
||
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
|
||
'negative_openness', 'aspect', 'sailore', 'aniso_open', '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 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 l'approximation affine _approx_l93_to_wgs84.
|
||
|
||
Ajoute à chaque tuile:
|
||
corners: [[lat, lon] × 4] dans l'ordre SW, SE, NE, NW
|
||
bounds : [[lat_sud, lon_ouest], [lat_nord, lon_est]]
|
||
"""
|
||
try:
|
||
from rasterio.warp import transform as warp_transform
|
||
xs = []
|
||
ys = []
|
||
for t in tiles:
|
||
# SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000
|
||
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])
|
||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
|
||
ok = True
|
||
except Exception as e:
|
||
logger.debug(f"Coins GPS approximatifs (rasterio indisponible: {e})")
|
||
lons = None
|
||
lats = None
|
||
ok = False
|
||
|
||
for i, t in enumerate(tiles):
|
||
if ok:
|
||
corners = [[lats[4 * i], lons[4 * i]],
|
||
[lats[4 * i + 1], lons[4 * i + 1]],
|
||
[lats[4 * i + 2], lons[4 * i + 2]],
|
||
[lats[4 * i + 3], lons[4 * i + 3]]]
|
||
else:
|
||
corners = []
|
||
for cx in (t['col'] * 1000, (t['col'] + 1) * 1000):
|
||
for cy in ((t['row'] - 1) * 1000, t['row'] * 1000):
|
||
lon, lat = _approx_l93_to_wgs84(cx, cy)
|
||
corners.append([lat, lon])
|
||
# Reordonner SW, SE, NE, NW (la boucle donne SW, NW, SE, NE)
|
||
corners = [corners[0], corners[2], corners[3], corners[1]]
|
||
t['corners'] = corners
|
||
t['bounds'] = [[min(c[0] for c in corners), min(c[1] for c in corners)],
|
||
[max(c[0] for c in corners), max(c[1] for c in corners)]]
|
||
return ok
|
||
|
||
|
||
def _mtime(path):
|
||
"""Mtime d'un fichier, ou None si inaccessible."""
|
||
try:
|
||
return Path(path).stat().st_mtime
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _cached_file_fresh(path, src_mtime):
|
||
"""True si un fichier cache existe et est plus récent que sa source.
|
||
|
||
Sert à invalider vignettes et sous-tuiles quand une tuile est recalculée :
|
||
l'image source (AVIF/WebP) étant réécrite, sa mtime devient plus récente
|
||
que celle du cache, qui doit alors être régénéré.
|
||
"""
|
||
cached_mtime = _mtime(path)
|
||
if cached_mtime is None:
|
||
return False
|
||
return src_mtime is None or cached_mtime >= src_mtime
|
||
|
||
|
||
def generate_thumbnail(src_path, thumb_path, max_size=256):
|
||
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
|
||
|
||
Args:
|
||
src_path: chemin de l'image source (AVIF/WebP).
|
||
thumb_path: chemin de sortie JPEG.
|
||
max_size: taille maximale (côté le plus grand) en pixels.
|
||
|
||
Returns:
|
||
True si OK, False si échec.
|
||
"""
|
||
try:
|
||
from PIL import Image as PILImage
|
||
except ImportError:
|
||
logger.warning("PIL indisponible — impossible de générer les vignettes")
|
||
return False
|
||
|
||
try:
|
||
img = PILImage.open(str(src_path))
|
||
img = img.convert('RGB')
|
||
w, h = img.size
|
||
scale = min(1.0, max_size / max(w, h))
|
||
if scale < 1.0:
|
||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||
try:
|
||
resample = PILImage.Resampling.LANCZOS
|
||
except AttributeError:
|
||
resample = getattr(PILImage, 'LANCZOS', 1)
|
||
img = img.resize(new_size, resample)
|
||
Path(thumb_path).parent.mkdir(parents=True, exist_ok=True)
|
||
img.save(str(thumb_path), format='JPEG', quality=80)
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(f"Vignette ignorée {src_path}: {e}")
|
||
return False
|
||
|
||
|
||
def _pick_display_viz(viz_keys):
|
||
"""Choisit la visualisation par défaut pour une tuile.
|
||
|
||
Privilégie hillshade_multi, sinon la première disponible selon l'ordre de repli.
|
||
"""
|
||
for v in _VIZ_FALLBACK_ORDER:
|
||
if v in viz_keys:
|
||
return v
|
||
return sorted(viz_keys)[0]
|
||
|
||
|
||
def _subdivision_k(resolution, tile_m=1000, target_px=2500):
|
||
"""Facteur de découpage k (grille k×k) pour alléger le rendu carte.
|
||
|
||
Au 0,2 m/px une dalle de 1 km fait 5000×5000 px (~100 Mo décodés dans le
|
||
navigateur) : on la découpe en quadrants de 500 m (k=2, 2500×2500 px).
|
||
À 0,5 m/px (2000 px) la dalle reste entière (k=1).
|
||
"""
|
||
px = max(1, int(round(tile_m / resolution)))
|
||
return max(1, int(round(px / target_px)))
|
||
|
||
|
||
def _subtile_corners(corners, i, j, k):
|
||
"""Coins WGS84 [SW, SE, NE, NW] de la sous-tuile (i, j) d'un découpage k×k.
|
||
|
||
i : indice vers l'est (0..k-1), j : indice vers le nord (0..k-1).
|
||
Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté
|
||
est quasi un parallélogramme à cette échelle (erreur écran < 1 px).
|
||
"""
|
||
sw, se, ne, nw = corners
|
||
|
||
def lerp(p, q, u):
|
||
return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]
|
||
|
||
def at(u, v):
|
||
return lerp(lerp(sw, se, u), lerp(nw, ne, u), v)
|
||
|
||
u0, u1 = i / k, (i + 1) / k
|
||
v0, v1 = j / k, (j + 1) / k
|
||
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
|
||
|
||
|
||
def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||
"""Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive.
|
||
|
||
Ne découpe que les visualisations proposées dans le panneau de couches. Retourne
|
||
la liste des entrées display (une par sous-tuile), ou None si le découpage
|
||
n'est pas nécessaire/possible (la dalle entière sera alors affichée).
|
||
"""
|
||
k = _subdivision_k(tile['resolution'])
|
||
if k <= 1:
|
||
return None
|
||
try:
|
||
from PIL import Image as PILImage
|
||
except ImportError:
|
||
return None
|
||
|
||
sub_dir = output_dir / sub_dir_name
|
||
sub_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
entries = {}
|
||
for j in range(k):
|
||
for i in range(k):
|
||
corners = _subtile_corners(tile['corners'], i, j, k)
|
||
entries[(i, j)] = {
|
||
'col': tile['col'], 'row': tile['row'],
|
||
'name': tile['name'], 'dir_name': tile['dir_name'],
|
||
'resolution': tile['resolution'],
|
||
'bounds': [[min(c[0] for c in corners), min(c[1] for c in corners)],
|
||
[max(c[0] for c in corners), max(c[1] for c in corners)]],
|
||
'corners': corners,
|
||
'display_viz': tile['display_viz'],
|
||
'viz': {},
|
||
'meta': tile.get('meta'),
|
||
'sub_i': i, 'sub_j': j, 'sub_k': k,
|
||
'size_km': round(1.0 / k, 3),
|
||
}
|
||
|
||
try:
|
||
resample = getattr(PILImage, 'LANCZOS', 1)
|
||
for viz_key in offered_viz_keys:
|
||
info = tile['viz'].get(viz_key)
|
||
if not info:
|
||
continue
|
||
stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}"
|
||
for key in entries}
|
||
# Régénère si au moins un fichier manque ou est périmé (dalle
|
||
# source recalculée depuis — comme les vignettes)
|
||
src = output_dir / info['full']
|
||
src_mtime = _mtime(src)
|
||
all_exist = all(
|
||
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
|
||
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime)
|
||
for stem in stems.values())
|
||
if not all_exist:
|
||
logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} "
|
||
f"({len(stems)} découpages)")
|
||
try:
|
||
img = PILImage.open(str(src))
|
||
img.load()
|
||
except Exception as e:
|
||
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
|
||
return None
|
||
if img.mode not in ('RGB', 'L'):
|
||
img = img.convert('RGB')
|
||
W, H = img.size
|
||
for (i, j), stem in stems.items():
|
||
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
|
||
left, right = round(W * i / k), round(W * (i + 1) / k)
|
||
top = round(H * (1 - (j + 1) / k))
|
||
bottom = round(H * (1 - j / k))
|
||
quad = img.crop((left, top, right, bottom))
|
||
try:
|
||
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
|
||
format='AVIF', quality=_SUBTILE_AVIF_QUALITY,
|
||
speed=_SUBTILE_AVIF_SPEED)
|
||
except Exception as e:
|
||
logger.warning(f"Encodage AVIF impossible ({stem}), "
|
||
f"sous-tuilage abandonné : {e}")
|
||
return None
|
||
# Supprime l'ancien crop .webp d'une génération précédente
|
||
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
|
||
scale = min(1.0, 256 / max(quad.size))
|
||
if scale < 1.0:
|
||
quad = quad.resize((max(1, int(quad.size[0] * scale)),
|
||
max(1, int(quad.size[1] * scale))), resample)
|
||
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
|
||
format='WEBP', quality=80)
|
||
for (i, j), stem in stems.items():
|
||
entries[(i, j)]['viz'][viz_key] = {
|
||
'thumb': f"{sub_dir_name}/{stem}_thumb.webp",
|
||
'full': f"{sub_dir_name}/{stem}.avif",
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
|
||
return None
|
||
|
||
usable = [e for e in entries.values() if e['viz']]
|
||
if not usable:
|
||
return None
|
||
# Repli dalle entière pour les visualisations non découpées : elles restent
|
||
# superposables en couche (images plus lourdes, mais fonctionnelles).
|
||
sub_keys = set(offered_viz_keys)
|
||
for entry in usable:
|
||
for viz_key, info in tile['viz'].items():
|
||
if viz_key not in sub_keys:
|
||
entry['viz'][viz_key] = dict(info)
|
||
return usable
|
||
|
||
|
||
def _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:
|
||
st = (Path(tile['dir_path']) / 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.
|
||
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():
|
||
best['viz'].setdefault(viz_key, viz_info)
|
||
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():
|
||
src = Path(t['dir_path']) / info['filename']
|
||
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
|
||
thumb_path = thumb_dir / thumb_name
|
||
# Régénère si manquante ou périmée (tuile recalculée depuis)
|
||
if not _cached_file_fresh(thumb_path, _mtime(src)):
|
||
if generate_thumbnail(src, thumb_path):
|
||
thumbs_generated += 1
|
||
regen += 1
|
||
else:
|
||
thumbs_failed += 1
|
||
continue
|
||
else:
|
||
thumbs_generated += 1
|
||
viz_thumbs[viz_key] = {
|
||
'thumb': f"index_thumbs/{thumb_name}",
|
||
'full': f"visualisations/{t['dir_name']}/{info['filename']}",
|
||
}
|
||
|
||
if regen:
|
||
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
|
||
f"{regen} vignette(s) régénérée(s)")
|
||
|
||
if not viz_thumbs:
|
||
continue
|
||
|
||
display_viz = _pick_display_viz(viz_thumbs.keys())
|
||
tile_meta = _collect_tile_metadata(t, dtm_dir)
|
||
zone_tile_records.append({
|
||
'col': t['col'],
|
||
'row': t['row'],
|
||
'name': t['basename'],
|
||
'dir_name': t['dir_name'],
|
||
'resolution': t['resolution'],
|
||
'bounds': t.get('bounds'),
|
||
'corners': t.get('corners'),
|
||
'display_viz': display_viz,
|
||
'viz': viz_thumbs,
|
||
'meta': tile_meta,
|
||
})
|
||
|
||
if zone_tile_records:
|
||
zone_records.append({
|
||
'label': zone['label'],
|
||
'tiles': zone_tile_records,
|
||
'bbox': zone['bbox'],
|
||
})
|
||
|
||
if not zone_records:
|
||
logger.warning("Aucune vignette générée — index global abandonné")
|
||
return None
|
||
|
||
# Calculer le bbox global (pour la stat bar)
|
||
global_bbox = compute_bbox(tiles)
|
||
|
||
# HTML avec données intégrées : liste plate des quads affichables.
|
||
# Les dalles 0,2 m (5000×5000 px) sont découpées en sous-tuiles 500 m
|
||
# (quadrants 2500×2500 px) pour alléger mémoire et chargements navigateur.
|
||
sub_dir_name = 'index_subtiles'
|
||
display_tiles = []
|
||
n_dalles = 0
|
||
n_sous = 0
|
||
for zr in zone_records:
|
||
for t in zr['tiles']:
|
||
n_dalles += 1
|
||
subs = _build_subtiles(t, sub_viz, output_dir, sub_dir_name)
|
||
if subs:
|
||
display_tiles.extend(subs)
|
||
n_sous += len(subs)
|
||
else:
|
||
display_tiles.append(t)
|
||
if n_sous:
|
||
logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)")
|
||
|
||
_write_assets(output_dir)
|
||
html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format)
|
||
html_path = output_dir / 'index.html'
|
||
html_path.write_text(html, encoding='utf-8')
|
||
|
||
logger.info(f"Index global généré : {html_path} ({time.time() - t_start:.1f}s)")
|
||
logger.info(f" {len(tiles)} tuile(s) • {thumbs_generated} vignette(s) générée(s)"
|
||
+ (f" • {thumbs_failed} échec(s)" if thumbs_failed else ""))
|
||
logger.info(f" Grille : {global_bbox['min_col']}-{global_bbox['max_col']} km E × "
|
||
f"{global_bbox['min_row']}-{global_bbox['max_row']} km N")
|
||
|
||
return html_path
|
||
|
||
|
||
def _write_assets(output_dir):
|
||
"""Écrit les fichiers statiques de l'interface (CSS + JS) dans output/assets/."""
|
||
assets_dir = Path(output_dir) / 'assets'
|
||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||
(assets_dir / 'app.css').write_text(_APP_CSS, encoding='utf-8')
|
||
(assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8')
|
||
|
||
|
||
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
||
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
|
||
# 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)
|
||
|
||
tiles_json = json.dumps(tiles, ensure_ascii=False)
|
||
|
||
# Options du sélecteur de visualisations de la barre de génération.
|
||
gen_viz_options = "\n".join(
|
||
f' <option value="{name}"{" selected" if name == "aspect" else ""}>'
|
||
f'{label}</option>'
|
||
for name, label in GEN_VIZ_CHOICES)
|
||
|
||
viz_meta_json = json.dumps({
|
||
k: {'label': VIZ_LABELS.get(k, k)}
|
||
for k in ordered_viz
|
||
}, ensure_ascii=False)
|
||
|
||
# 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)
|
||
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
|
||
ensure_ascii=False)
|
||
|
||
n_tiles = len(tiles)
|
||
grid_w = global_bbox['max_col'] - global_bbox['min_col'] + 1
|
||
grid_h = global_bbox['max_row'] - global_bbox['min_row'] + 1
|
||
|
||
return _HTML_TEMPLATE.format(
|
||
tiles_json=tiles_json,
|
||
viz_meta_json=viz_meta_json,
|
||
stats_json=stats_json,
|
||
gen_viz_options=gen_viz_options,
|
||
n_tiles=n_tiles,
|
||
grid_w=grid_w,
|
||
grid_h=grid_h,
|
||
output_format=output_format.upper(),
|
||
)
|
||
|
||
|
||
_HTML_TEMPLATE = """<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Carte LiDAR</title>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||
<link rel="stylesheet" href="assets/app.css"/>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="viewport"></div>
|
||
|
||
<header id="brand" class="panel">
|
||
<h1>Carte LiDAR</h1>
|
||
<span class="stat" id="statTiles">{n_tiles} tuile(s) · {grid_w}×{grid_h} km · {output_format}</span>
|
||
</header>
|
||
|
||
<nav id="mapTools" class="panel">
|
||
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
|
||
<button id="zoomIn" title="Zoomer">+</button>
|
||
<button id="zoomOut" title="Dézoomer">−</button>
|
||
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
|
||
</nav>
|
||
|
||
<aside id="layerPanel" class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">Couches</span>
|
||
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
|
||
<button id="layerCollapse" class="icon-btn" title="Replier / déplier">▾</button>
|
||
</div>
|
||
<div id="layerBody">
|
||
<div class="layer-row" id="osmRow">
|
||
<button class="eye on" id="osmToggle" title="Afficher / masquer le fond de carte"></button>
|
||
<span class="layer-name">Fond de carte OSM</span>
|
||
<input type="range" id="osmOpacity" min="0" max="100" step="1" title="Opacité du fond">
|
||
<span class="layer-pct" id="osmPct"></span>
|
||
</div>
|
||
<div class="layer-sub">Visualisations · glisser pour réordonner</div>
|
||
<div id="vizLayers"></div>
|
||
</div>
|
||
</aside>
|
||
|
||
<aside id="infoPanel" class="panel" hidden>
|
||
<div class="panel-head">
|
||
<span class="panel-title" id="infoTitle">Tuile</span>
|
||
<button id="infoClose" class="icon-btn" title="Fermer">×</button>
|
||
</div>
|
||
<div id="infoBody"></div>
|
||
</aside>
|
||
|
||
<div id="zoomIndicator" class="chip">Zoom</div>
|
||
<div id="hint" class="chip">
|
||
Molette : zoom · Clic-glisser : déplacer<br>
|
||
Clic sur tuile : infos de génération
|
||
</div>
|
||
|
||
<!-- Barre de confirmation de génération de zone -->
|
||
<div id="genBar" class="panel">
|
||
<span id="genMsg"></span>
|
||
<label class="gen-opt">
|
||
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
|
||
</label>
|
||
<label class="gen-opt" title="Ctrl+clic pour sélectionner plusieurs visualisations">
|
||
Visualisations :
|
||
<select id="genViz" class="gen-select" multiple size="4">
|
||
{gen_viz_options}
|
||
</select>
|
||
</label>
|
||
<label class="gen-opt">
|
||
Classification :
|
||
<select id="genClass" class="gen-select">
|
||
<option value="auto">Auto (détection)</option>
|
||
<option value="ign" selected>IGN (pré-classification)</option>
|
||
<option value="smrf">SMRF</option>
|
||
<option value="csf">CSF</option>
|
||
</select>
|
||
<select id="genIgnClasses" class="gen-select" title="Classes LAS extraites pour le MNT (méthode IGN, mode pur)">
|
||
<option value="sol" selected>Sol (2)</option>
|
||
<option value="sol,unclassified">Sol + non-classé (2+1)</option>
|
||
<option value="sol,unclassified,eau,virtuel">Sol+non-classé+eau+virtuel (2+1+9+66)</option>
|
||
</select>
|
||
</label>
|
||
<label class="gen-opt" title="Ramène le MNT au retour le plus bas de chaque cellule (sol nu)">
|
||
<input type="checkbox" id="genBareEarth"> Sol nu
|
||
</label>
|
||
<button id="genGo" class="primary">Lancer</button>
|
||
<button id="genCancel">Annuler</button>
|
||
</div>
|
||
|
||
<!-- File de génération : panneau latéral (le journal n'est plus au 1er plan) -->
|
||
<aside id="genQueue" class="panel" hidden>
|
||
<div class="panel-head">
|
||
<span class="panel-title">File de génération</span>
|
||
<div class="gen-queue-actions">
|
||
<span id="genQueueCount" class="gen-queue-count"></span>
|
||
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
|
||
</div>
|
||
</div>
|
||
<div id="genQueueBody"></div>
|
||
</aside>
|
||
|
||
<script>
|
||
const TILES = {tiles_json};
|
||
const VIZ_META = {viz_meta_json};
|
||
const STATS = {stats_json};
|
||
</script>
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script src="assets/app.js"></script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
_APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */
|
||
|
||
:root {
|
||
--bg: #0b0e13;
|
||
--panel: rgba(16, 20, 27, 0.9);
|
||
--panel-border: rgba(255, 255, 255, 0.09);
|
||
--text: #d9dee6;
|
||
--muted: #8b94a3;
|
||
--accent: #5aa9e6;
|
||
--accent-dim: #2e7cc4;
|
||
--danger: #e94560;
|
||
--radius: 10px;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body {
|
||
height: 100%; overflow: hidden;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg); color: var(--text);
|
||
font-size: 13px;
|
||
}
|
||
|
||
#viewport { position: absolute; inset: 0; background: var(--bg); }
|
||
.leaflet-container { background: var(--bg); font: inherit; }
|
||
/* Fond OSM assombri pour faire ressortir les tuiles LiDAR */
|
||
#viewport .leaflet-tile-pane { filter: grayscale(0.6) brightness(0.5) contrast(1.2); }
|
||
#viewport.bright .leaflet-tile-pane { filter: none; }
|
||
|
||
/* --- Panneaux flottants --- */
|
||
.panel {
|
||
position: fixed; z-index: 900;
|
||
background: var(--panel);
|
||
border: 1px solid var(--panel-border);
|
||
border-radius: var(--radius);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||
color: var(--text);
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 8px 12px; border-bottom: 1px solid var(--panel-border);
|
||
}
|
||
.panel-title { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
||
|
||
button {
|
||
background: rgba(255, 255, 255, 0.06); color: var(--text);
|
||
border: 1px solid var(--panel-border); border-radius: 6px;
|
||
padding: 6px 12px; font-size: 13px; cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
|
||
button.primary { background: var(--accent-dim); border-color: var(--accent-dim); }
|
||
button.primary:hover { background: var(--accent); }
|
||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
.icon-btn { padding: 2px 8px; font-size: 14px; line-height: 1.2; }
|
||
|
||
select, input[type="checkbox"] { accent-color: var(--accent); }
|
||
.gen-select {
|
||
background: rgba(255, 255, 255, 0.06); color: var(--text);
|
||
border: 1px solid var(--panel-border); border-radius: 4px;
|
||
padding: 3px 6px; font-size: 12px; cursor: pointer;
|
||
}
|
||
|
||
/* --- En-tête (marque + stats) --- */
|
||
#brand { top: 12px; left: 12px; padding: 8px 14px; }
|
||
#brand h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; }
|
||
#brand .stat { font-size: 11.5px; color: var(--muted); }
|
||
|
||
/* --- Outils carte (haut droite) --- */
|
||
#mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; }
|
||
#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; }
|
||
#mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); }
|
||
#mapTools #btnGenZone:hover { background: var(--accent); }
|
||
|
||
/* --- Panneau de couches (gauche) --- */
|
||
#layerPanel { top: 74px; left: 12px; width: 300px; }
|
||
#layerPanel.collapsed #layerBody { display: none; }
|
||
#layerPanel.collapsed #layerCollapse { transform: rotate(-90deg); }
|
||
#layerCollapse { border: none; background: none; color: var(--muted); }
|
||
#layerBody { padding: 6px; max-height: calc(100vh - 200px); overflow-y: auto; }
|
||
|
||
.layer-row {
|
||
display: grid; grid-template-columns: 26px 1fr 74px 36px;
|
||
align-items: center; gap: 6px;
|
||
padding: 5px 6px; border-radius: 6px;
|
||
}
|
||
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
|
||
.layer-row.dragging { opacity: 0.45; }
|
||
.layer-row[data-layer] { cursor: grab; }
|
||
|
||
.eye {
|
||
width: 24px; height: 24px; padding: 0; border: none; background: none;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--muted);
|
||
}
|
||
.eye.on { color: var(--accent); }
|
||
.eye svg { width: 16px; height: 16px; }
|
||
|
||
.layer-name {
|
||
font-size: 12.5px; white-space: nowrap; overflow: hidden;
|
||
text-overflow: ellipsis; color: var(--muted);
|
||
}
|
||
.layer-row.on .layer-name { color: var(--text); }
|
||
.layer-pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||
|
||
.layer-sub {
|
||
font-size: 10.5px; color: var(--muted); text-transform: uppercase;
|
||
letter-spacing: 0.05em; padding: 8px 6px 4px;
|
||
border-top: 1px solid var(--panel-border); margin-top: 4px;
|
||
}
|
||
|
||
input[type="range"] {
|
||
-webkit-appearance: none; appearance: none;
|
||
width: 100%; height: 3px; border-radius: 2px;
|
||
background: rgba(255, 255, 255, 0.15); outline: none; cursor: pointer;
|
||
}
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none; appearance: none;
|
||
width: 11px; height: 11px; border-radius: 50%;
|
||
background: var(--accent); border: none;
|
||
}
|
||
input[type="range"]::-moz-range-thumb {
|
||
width: 11px; height: 11px; border-radius: 50%;
|
||
background: var(--accent); border: none;
|
||
}
|
||
|
||
/* --- Tuiles LiDAR sur la carte --- */
|
||
.ltile-anchor { width: 0; height: 0; }
|
||
.ltile {
|
||
position: absolute; left: 0; top: 0; overflow: hidden;
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
/* Fond transparent obligatoire : l'opacité des couches doit laisser voir
|
||
les couches du dessous et le fond de carte, pas un rectangle noir. */
|
||
background: transparent;
|
||
}
|
||
.ltile img {
|
||
position: absolute; inset: 0;
|
||
width: 100%; height: 100%; object-fit: fill; display: block;
|
||
/* Évite le drag natif des images qui avale le mouseup (dessin de zones) */
|
||
pointer-events: none; -webkit-user-drag: none; user-select: none;
|
||
}
|
||
.ltile:hover { border-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); }
|
||
.ltile.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); }
|
||
|
||
/* --- Panneau d'infos tuile (droite) --- */
|
||
#infoPanel { top: 60px; right: 12px; width: 330px; }
|
||
#infoBody { padding: 10px 12px; max-height: calc(100vh - 180px); overflow-y: auto; }
|
||
#infoClose { border: none; background: none; color: var(--muted); font-size: 18px; }
|
||
#infoClose:hover { color: var(--danger); }
|
||
|
||
.meta-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin-bottom: 10px; }
|
||
.meta-list dt { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px; }
|
||
.meta-list dd { font-size: 12.5px; }
|
||
.meta-list dd.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; }
|
||
|
||
.info-sub {
|
||
font-size: 10.5px; color: var(--muted); text-transform: uppercase;
|
||
letter-spacing: 0.05em; margin: 10px 0 4px;
|
||
border-top: 1px solid var(--panel-border); padding-top: 8px;
|
||
}
|
||
|
||
.viz-row {
|
||
display: grid; grid-template-columns: 22px 1fr 88px 52px 22px;
|
||
align-items: center; gap: 4px; padding: 3px 2px; border-radius: 4px;
|
||
}
|
||
.viz-row:hover { background: rgba(255, 255, 255, 0.05); }
|
||
.viz-row .mini {
|
||
width: 20px; height: 20px; padding: 0; border: none; background: none;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--muted); font-size: 13px; text-decoration: none;
|
||
}
|
||
.viz-row .mini:hover { color: var(--accent); }
|
||
.viz-row .mini svg { width: 14px; height: 14px; }
|
||
.viz-name { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.viz-date { font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; }
|
||
.viz-size { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||
|
||
#infoActions { margin-top: 12px; display: flex; gap: 8px; }
|
||
|
||
/* --- Puces d'état (bas d'écran) --- */
|
||
.chip {
|
||
position: fixed; z-index: 900;
|
||
background: var(--panel); border: 1px solid var(--panel-border);
|
||
border-radius: 6px; padding: 5px 10px;
|
||
font-size: 11px; color: var(--muted);
|
||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||
}
|
||
#zoomIndicator { bottom: 12px; left: 12px; font-variant-numeric: tabular-nums; }
|
||
#hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; }
|
||
|
||
/* --- Génération de zones --- */
|
||
#genBar {
|
||
bottom: 48px; left: 50%; transform: translateX(-50%);
|
||
display: none; align-items: center; gap: 12px;
|
||
padding: 10px 14px; max-width: min(96vw, 900px); flex-wrap: wrap;
|
||
}
|
||
#genBar.show { display: flex; }
|
||
#genMsg { font-size: 12.5px; }
|
||
.gen-opt {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
|
||
}
|
||
|
||
/* File de génération : panneau latéral, le journal replié par défaut */
|
||
#genQueue { bottom: 48px; right: 12px; width: 780px; max-width: 94vw; }
|
||
#genQueueBody {
|
||
padding: 8px 10px; max-height: 42vh; overflow-y: auto;
|
||
display: flex; flex-direction: column; gap: 8px;
|
||
}
|
||
.gen-queue-actions { display: flex; align-items: center; gap: 8px; }
|
||
.gen-queue-count { font-size: 11px; color: var(--muted); }
|
||
.gen-task { border: 1px solid var(--panel-border); border-radius: 6px; }
|
||
.gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; }
|
||
.gen-task-dot {
|
||
width: 9px; height: 9px; border-radius: 50%; flex: none;
|
||
background: var(--muted);
|
||
}
|
||
.gen-task.running .gen-task-dot {
|
||
background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
|
||
}
|
||
.gen-task.done .gen-task-dot { background: #39d98a; }
|
||
.gen-task.failed .gen-task-dot { background: var(--danger); }
|
||
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||
.gen-task-label {
|
||
font-size: 12px; flex: 1; min-width: 0;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.gen-task-state { font-size: 11px; white-space: nowrap; }
|
||
.gen-task.running .gen-task-state { color: var(--accent); }
|
||
.gen-task.done .gen-task-state { color: #39d98a; }
|
||
.gen-task.failed .gen-task-state { color: var(--danger); }
|
||
.gen-task-time {
|
||
font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums;
|
||
}
|
||
.gen-task-toggle { padding: 1px 6px; font-size: 11px; }
|
||
.gen-task-log {
|
||
margin: 0 8px 8px; max-height: 160px; overflow: auto;
|
||
background: #0d1017; border: 1px solid var(--panel-border); border-radius: 6px;
|
||
padding: 8px 10px; font: 10.5px/1.45 ui-monospace, Menlo, monospace;
|
||
color: #9fb3d9; white-space: pre-wrap;
|
||
}
|
||
|
||
body.drawing, body.drawing #viewport { cursor: crosshair !important; }
|
||
|
||
/* --- Responsive --- */
|
||
@media (max-width: 720px) {
|
||
#layerPanel { width: min(280px, 62vw); }
|
||
#infoPanel { width: min(310px, 66vw); top: auto; bottom: 48px; }
|
||
#hint { display: none; }
|
||
}
|
||
"""
|
||
|
||
|
||
_APP_JS = r"""'use strict';
|
||
/* Carte LiDAR — couches superposables, infos tuiles, génération de zones. */
|
||
|
||
// --- Données embarquées par index.py ---
|
||
// TILES : tuiles affichables (corners WGS84, viz {thumb, full}, meta)
|
||
// VIZ_META : {clé: {label}} des visualisations disponibles
|
||
// STATS : {default_viz, n_tiles}
|
||
|
||
const viewport = document.getElementById('viewport');
|
||
|
||
if (typeof L === 'undefined') {
|
||
viewport.innerHTML =
|
||
'<div style="padding:24px;color:#e94560;font-family:sans-serif;font-size:14px">' +
|
||
"Erreur : Leaflet n'a pas pu être chargé (accès Internet requis pour ouvrir cette page en ligne)." +
|
||
'</div>';
|
||
} else {
|
||
|
||
// ============================================================
|
||
// Carte Leaflet : zoom, pan, fond OSM
|
||
// ============================================================
|
||
const map = L.map('viewport', {
|
||
zoomControl: false,
|
||
attributionControl: false,
|
||
minZoom: 4,
|
||
maxZoom: 19
|
||
});
|
||
window.map = map; // accès débogage / automatisations
|
||
window.TILES = TILES;
|
||
|
||
const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19
|
||
}).addTo(map);
|
||
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
|
||
|
||
// ============================================================
|
||
// État des couches (persisté en localStorage)
|
||
// ============================================================
|
||
// order : pile de visualisations, index 0 = bas de pile.
|
||
const LS_KEY = 'lidarLayers_v1';
|
||
|
||
function defaultLayers() {
|
||
const order = Object.keys(VIZ_META);
|
||
const base = STATS.default_viz && VIZ_META[STATS.default_viz]
|
||
? STATS.default_viz : order[0];
|
||
const on = {}, opacity = {};
|
||
for (const k of order) { on[k] = (k === base); opacity[k] = 1; }
|
||
return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } };
|
||
}
|
||
|
||
function loadLayers() {
|
||
const def = defaultLayers();
|
||
try {
|
||
const raw = localStorage.getItem(LS_KEY);
|
||
if (!raw) return def;
|
||
const saved = JSON.parse(raw);
|
||
const order = (Array.isArray(saved.order) ? saved.order : [])
|
||
.filter(k => VIZ_META[k]);
|
||
for (const k of Object.keys(VIZ_META)) {
|
||
if (!order.includes(k)) order.push(k);
|
||
}
|
||
const on = {}, opacity = {};
|
||
for (const k of order) {
|
||
const op = saved.opacity ? saved.opacity[k] : undefined;
|
||
on[k] = saved.on && saved.on[k] === true;
|
||
opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op)))
|
||
? 1 : Math.min(1, Math.max(0, Number(op)));
|
||
}
|
||
const osm = Object.assign({}, def.osm, saved.osm || {});
|
||
return { order, on, opacity, osm };
|
||
} catch (e) { return def; }
|
||
}
|
||
|
||
const state = { layers: loadLayers() };
|
||
|
||
function saveLayers() {
|
||
try { localStorage.setItem(LS_KEY, JSON.stringify(state.layers)); } catch (e) { /* privé */ }
|
||
}
|
||
|
||
function activeLayers() {
|
||
return state.layers.order.filter(k => state.layers.on[k] && VIZ_META[k]);
|
||
}
|
||
|
||
// ============================================================
|
||
// Marqueurs : une div par tuile, une <img> par couche active
|
||
// ============================================================
|
||
let gridBounds = null;
|
||
const markers = new Map();
|
||
const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0);
|
||
|
||
for (const t of TILES) {
|
||
if (!t.corners) continue;
|
||
const center = [(t.corners[0][0] + t.corners[2][0]) / 2,
|
||
(t.corners[0][1] + t.corners[2][1]) / 2];
|
||
const icon = L.divIcon({
|
||
className: 'ltile-anchor',
|
||
html: '<div class="ltile"></div>',
|
||
iconSize: [0, 0]
|
||
});
|
||
const mk = L.marker(L.latLng(center[0], center[1]), {
|
||
icon: icon,
|
||
interactive: true,
|
||
keyboard: false
|
||
}).addTo(map);
|
||
mk.on('click', () => { if (!genMode) showTileInfo(t); });
|
||
markers.set(tileKey(t), mk);
|
||
const b = L.latLngBounds(t.corners);
|
||
gridBounds = gridBounds ? gridBounds.extend(b) : b;
|
||
}
|
||
|
||
function tileDiv(t) {
|
||
const mk = markers.get(tileKey(t));
|
||
const el = mk && mk.getElement();
|
||
return el ? el.querySelector('.ltile') : null;
|
||
}
|
||
|
||
// Redimensionne + rote chaque tuile selon les coins projetés à l'échelle courante
|
||
function layoutTiles() {
|
||
// map.project() renvoie des pixels NON arrondis, contrairement à
|
||
// latLngToContainerPoint() (arrondi entier) : au zoom global un bord de tuile
|
||
// de 1 km ne fait que ~0,6 px et l'arrondi l'écrase à 0 (rotations fausses
|
||
// de 0/45/90°, largeurs 0 ou 1 px, tuiles qui ne s'emboîtent plus).
|
||
const zoom = map.getZoom();
|
||
for (const t of TILES) {
|
||
const div = tileDiv(t);
|
||
if (!div) continue;
|
||
const [sw, se, ne, nw] = t.corners.map(c => map.project(L.latLng(c[0], c[1]), zoom));
|
||
const w = Math.hypot(se.x - sw.x, se.y - sw.y);
|
||
const h = Math.hypot(nw.x - sw.x, nw.y - sw.y);
|
||
const ang = Math.atan2(se.y - sw.y, se.x - sw.x) * 180 / Math.PI;
|
||
// Compense l'arrondi entier de la position du marqueur (erreur <= 0,5 px)
|
||
// pour que le bord partagé de 2 tuiles voisines coïncide exactement.
|
||
const cx = (sw.x + ne.x) / 2, cy = (sw.y + ne.y) / 2;
|
||
div.style.marginLeft = (cx - Math.round(cx)) + 'px';
|
||
div.style.marginTop = (cy - Math.round(cy)) + 'px';
|
||
div.style.width = w + 'px';
|
||
div.style.height = h + 'px';
|
||
div.style.transform = 'translate(-50%, -50%) rotate(' + ang.toFixed(4) + 'deg)';
|
||
}
|
||
// Resynchronise aussi les <img> : au premier rendu les marqueurs viennent
|
||
// tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés.
|
||
syncTileLayers();
|
||
}
|
||
|
||
// Crée / supprime les <img> de chaque tuile selon les couches actives,
|
||
// applique opacité et ordre d'empilement (z-index).
|
||
function syncTileLayers() {
|
||
const act = activeLayers();
|
||
for (const t of TILES) {
|
||
const div = tileDiv(t);
|
||
if (!div) continue;
|
||
div.querySelectorAll('img[data-layer]').forEach(img => {
|
||
if (!act.includes(img.dataset.layer)) { img.src = ''; img.remove(); }
|
||
});
|
||
act.forEach((k, i) => {
|
||
if (!t.viz[k]) return;
|
||
let img = div.querySelector('img[data-layer="' + CSS.escape(k) + '"]');
|
||
if (!img) {
|
||
img = document.createElement('img');
|
||
img.dataset.layer = k;
|
||
img.alt = '';
|
||
div.appendChild(img);
|
||
}
|
||
img.style.zIndex = String(i + 1);
|
||
img.style.opacity = String(state.layers.opacity[k]);
|
||
});
|
||
}
|
||
updateImages();
|
||
}
|
||
|
||
// Bascule vignette (256 px) ↔ image pleine résolution selon le zoom :
|
||
// en vue d'ensemble la vignette suffit ; quand une tuile dépasse ~300 px à
|
||
// l'écran ET est visible dans le viewport (marge de préchargement), on charge
|
||
// l'image complète pour conserver les détails. En dézoomant on retombe sur
|
||
// la vignette pour libérer la mémoire.
|
||
function updateImages() {
|
||
const vp = viewport.getBoundingClientRect();
|
||
const marge = 300;
|
||
const act = new Set(activeLayers());
|
||
for (const t of TILES) {
|
||
const div = tileDiv(t);
|
||
if (!div) continue;
|
||
const r = div.getBoundingClientRect();
|
||
const visible = r.right > vp.left - marge && r.left < vp.right + marge &&
|
||
r.bottom > vp.top - marge && r.top < vp.bottom + marge;
|
||
const big = visible && (r.width > 300 || r.height > 300);
|
||
for (const img of div.querySelectorAll('img[data-layer]')) {
|
||
const k = img.dataset.layer;
|
||
if (!act.has(k)) continue;
|
||
const v = t.viz[k];
|
||
if (!v) continue;
|
||
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
|
||
const want = (big && state.layers.opacity[k] > 0.15) ? v.full : v.thumb;
|
||
if (!img.src.endsWith(want)) img.src = want;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Panneau de couches
|
||
// ============================================================
|
||
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||
|
||
const vizLayersEl = document.getElementById('vizLayers');
|
||
let dragKey = null;
|
||
|
||
function renderOsmRow() {
|
||
const { on, opacity, dark } = state.layers.osm;
|
||
const toggle = document.getElementById('osmToggle');
|
||
const slider = document.getElementById('osmOpacity');
|
||
const pct = document.getElementById('osmPct');
|
||
toggle.classList.toggle('on', on);
|
||
toggle.innerHTML = on ? SVG_EYE : SVG_EYE_OFF;
|
||
slider.value = Math.round(opacity * 100);
|
||
pct.textContent = Math.round(opacity * 100) + '%';
|
||
if (on) { if (!map.hasLayer(osmLayer)) osmLayer.addTo(map); }
|
||
else { if (map.hasLayer(osmLayer)) map.removeLayer(osmLayer); }
|
||
osmLayer.setOpacity(opacity);
|
||
viewport.classList.toggle('bright', !dark);
|
||
}
|
||
|
||
function renderLayerPanel() {
|
||
vizLayersEl.innerHTML = '';
|
||
// Affichage haut de pile en premier (le tableau order est bas → haut).
|
||
const topFirst = state.layers.order.slice().reverse();
|
||
for (const key of topFirst) {
|
||
const meta = VIZ_META[key];
|
||
if (!meta) continue;
|
||
const row = document.createElement('div');
|
||
row.className = 'layer-row' + (state.layers.on[key] ? ' on' : '');
|
||
row.draggable = true;
|
||
row.dataset.layer = key;
|
||
row.title = meta.label;
|
||
row.innerHTML =
|
||
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
|
||
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
|
||
'<span class="layer-name">' + meta.label + '</span>' +
|
||
'<input type="range" min="0" max="100" step="1" value="' +
|
||
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
|
||
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
|
||
|
||
const eye = row.querySelector('.eye');
|
||
const range = row.querySelector('input[type="range"]');
|
||
const pct = row.querySelector('.layer-pct');
|
||
|
||
eye.addEventListener('click', () => {
|
||
state.layers.on[key] = !state.layers.on[key];
|
||
// Réactivation sur une opacité nulle : on remonte à 100 %
|
||
if (state.layers.on[key] && state.layers.opacity[key] === 0) state.layers.opacity[key] = 1;
|
||
saveLayers();
|
||
renderLayerPanel();
|
||
syncTileLayers();
|
||
});
|
||
range.addEventListener('input', () => {
|
||
const v = Number(range.value) / 100;
|
||
state.layers.opacity[key] = v;
|
||
pct.textContent = range.value + '%';
|
||
// Applique directement sans reconstruire le DOM
|
||
for (const t of TILES) {
|
||
const div = tileDiv(t);
|
||
const img = div && div.querySelector('img[data-layer="' + CSS.escape(key) + '"]');
|
||
if (img) img.style.opacity = String(v);
|
||
}
|
||
updateImages();
|
||
});
|
||
range.addEventListener('change', saveLayers);
|
||
|
||
row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); });
|
||
row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); });
|
||
row.addEventListener('dragover', e => e.preventDefault());
|
||
row.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
if (!dragKey || dragKey === key) return;
|
||
const ord = state.layers.order;
|
||
ord.splice(ord.indexOf(dragKey), 1);
|
||
ord.splice(ord.indexOf(key), 0, dragKey);
|
||
saveLayers();
|
||
renderLayerPanel();
|
||
syncTileLayers();
|
||
});
|
||
vizLayersEl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
document.getElementById('osmToggle').addEventListener('click', () => {
|
||
state.layers.osm.on = !state.layers.osm.on;
|
||
saveLayers(); renderOsmRow();
|
||
});
|
||
document.getElementById('osmOpacity').addEventListener('input', e => {
|
||
state.layers.osm.opacity = Number(e.target.value) / 100;
|
||
osmLayer.setOpacity(state.layers.osm.opacity);
|
||
document.getElementById('osmPct').textContent = e.target.value + '%';
|
||
});
|
||
document.getElementById('osmOpacity').addEventListener('change', saveLayers);
|
||
// Clic sur le nom du fond = bascule mode sombre / clair
|
||
document.getElementById('osmRow').querySelector('.layer-name')
|
||
.addEventListener('click', () => {
|
||
state.layers.osm.dark = !state.layers.osm.dark;
|
||
saveLayers(); renderOsmRow();
|
||
});
|
||
document.getElementById('osmRow').querySelector('.layer-name').title =
|
||
'Cliquer : fond de carte clair / assombri';
|
||
|
||
const layerPanel = document.getElementById('layerPanel');
|
||
document.getElementById('layerCollapse').addEventListener('click', () => {
|
||
layerPanel.classList.toggle('collapsed');
|
||
try { localStorage.setItem('lidarLayersCollapsed', layerPanel.classList.contains('collapsed') ? '1' : ''); } catch (e) {}
|
||
});
|
||
try {
|
||
if (localStorage.getItem('lidarLayersCollapsed') === '1') layerPanel.classList.add('collapsed');
|
||
} catch (e) {}
|
||
|
||
// ============================================================
|
||
// Vue : zoom / pan / persistance (#zoom,lat,lng dans l'URL)
|
||
// ============================================================
|
||
function readViewHash() {
|
||
const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)$/.exec(location.hash);
|
||
if (m && !isNaN(parseFloat(m[1])) && !isNaN(parseFloat(m[2])) && !isNaN(parseFloat(m[3]))) {
|
||
return { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]) };
|
||
}
|
||
return null;
|
||
}
|
||
function saveViewHash() {
|
||
const c = map.getCenter();
|
||
history.replaceState(null, '', '#' +
|
||
map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6));
|
||
}
|
||
|
||
function fitView() {
|
||
// Si une vue a été sauvegardée dans l'URL (F5 / lien partagé), on la
|
||
// restaure au lieu de recadrer sur l'ensemble des tuiles.
|
||
const v = readViewHash();
|
||
if (v) { map.setView([v.lat, v.lng], v.zoom); return; }
|
||
// Fallback : vue sauvegardée en sessionStorage (après une génération).
|
||
const saved = sessionStorage.getItem('lidarMap');
|
||
if (saved) {
|
||
try {
|
||
const v = JSON.parse(saved);
|
||
if (v.lat != null && v.lng != null && v.zoom != null) {
|
||
map.setView([v.lat, v.lng], v.zoom);
|
||
return;
|
||
}
|
||
} catch (e) { /* stockage illisible : on retombe sur fitBounds */ }
|
||
}
|
||
if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] });
|
||
}
|
||
|
||
const zoomInd = document.getElementById('zoomIndicator');
|
||
document.getElementById('zoomIn').addEventListener('click', () => map.zoomIn(1));
|
||
document.getElementById('zoomOut').addEventListener('click', () => map.zoomOut(1));
|
||
document.getElementById('zoomReset').addEventListener('click', () => {
|
||
history.replaceState(null, '', location.pathname + location.search);
|
||
if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] });
|
||
});
|
||
map.on('zoomend', () => {
|
||
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
||
layoutTiles();
|
||
});
|
||
map.on('moveend', () => { updateImages(); saveViewHash(); });
|
||
map.on('click', hideTileInfo);
|
||
map.whenReady(() => layoutTiles());
|
||
|
||
// ============================================================
|
||
// Panneau d'infos tuile (métadonnées de génération)
|
||
// ============================================================
|
||
const infoPanel = document.getElementById('infoPanel');
|
||
const infoTitle = document.getElementById('infoTitle');
|
||
const infoBody = document.getElementById('infoBody');
|
||
let currentTile = null;
|
||
|
||
function methodLabel(m) {
|
||
if (!m) return 'inconnue';
|
||
if (m === 'ign') return 'IGN — sol (2)';
|
||
if (m.startsWith('ign_')) return 'IGN — classes ' + m.split('_').slice(1).join(', ');
|
||
return { smrf: 'SMRF', csf: 'CSF', auto: 'Auto (détection)' }[m] || m;
|
||
}
|
||
|
||
function humanSize(bytes) {
|
||
if (!Number.isFinite(bytes)) return '';
|
||
if (bytes < 1024) return bytes + ' o';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' Ko';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
|
||
}
|
||
|
||
function layerPos(key) {
|
||
const i = state.layers.order.indexOf(key);
|
||
return i === -1 ? 999 : i;
|
||
}
|
||
|
||
function showTileInfo(t) {
|
||
currentTile = t;
|
||
const meta = t.meta || {};
|
||
const vizRows = Object.keys(t.viz)
|
||
.sort((a, b) => layerPos(a) - layerPos(b))
|
||
.map(k => {
|
||
const vm = meta.viz && meta.viz[k];
|
||
return '<div class="viz-row">' +
|
||
'<button class="mini" data-act="show" data-viz="' + k + '" title="Afficher cette couche">' + SVG_EYE + '</button>' +
|
||
'<span class="viz-name" title="' + (VIZ_META[k] ? VIZ_META[k].label : k) + '">' +
|
||
(VIZ_META[k] ? VIZ_META[k].label : k) + '</span>' +
|
||
'<span class="viz-date">' + (vm ? vm.date : '') + '</span>' +
|
||
'<span class="viz-size">' + (vm ? humanSize(vm.size) : '') + '</span>' +
|
||
'<a class="mini" href="' + t.viz[k].full + '" download title="Télécharger l\'image">⤓</a>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
infoTitle.textContent = 'Tuile ' + t.col + ',' + t.row;
|
||
infoBody.innerHTML =
|
||
'<dl class="meta-list">' +
|
||
'<dt>Dalle</dt><dd class="mono">' + t.name + '</dd>' +
|
||
'<dt>Coordonnées L93</dt><dd class="mono">' + t.col + ' km E · ' + t.row + ' km N</dd>' +
|
||
'<dt>Résolution</dt><dd>' + t.resolution + ' m/px</dd>' +
|
||
'<dt>Taille</dt><dd>' + (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km</dd>' +
|
||
'<dt>Classification sol</dt><dd>' + methodLabel(meta.method) + '</dd>' +
|
||
'<dt>Générée le</dt><dd>' + (meta.generated || '?') + '</dd>' +
|
||
'</dl>' +
|
||
'<div class="info-sub">Visualisations</div>' +
|
||
vizRows +
|
||
'<div id="infoActions">' +
|
||
'<button id="infoRegen"' + (apiAvailable ? '' : ' disabled title="API indisponible — lancez ./run.sh --serve"') +
|
||
'>Régénérer cette tuile</button>' +
|
||
'</div>';
|
||
|
||
infoBody.querySelectorAll('button[data-act="show"]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const k = btn.dataset.viz;
|
||
if (!VIZ_META[k]) return;
|
||
state.layers.on[k] = true;
|
||
if (state.layers.opacity[k] === 0) state.layers.opacity[k] = 1;
|
||
saveLayers();
|
||
renderLayerPanel();
|
||
syncTileLayers();
|
||
layerPanel.classList.remove('collapsed');
|
||
});
|
||
});
|
||
const regen = document.getElementById('infoRegen');
|
||
if (regen) regen.addEventListener('click', () => regenerateCurrentTile());
|
||
|
||
// Surlignage de la tuile sélectionnée
|
||
for (const tk of TILES) {
|
||
const div = tileDiv(tk);
|
||
if (div) div.classList.toggle('selected', tk === t);
|
||
}
|
||
infoPanel.hidden = false;
|
||
}
|
||
|
||
function hideTileInfo() {
|
||
infoPanel.hidden = true;
|
||
currentTile = null;
|
||
for (const t of TILES) {
|
||
const div = tileDiv(t);
|
||
if (div) div.classList.remove('selected');
|
||
}
|
||
}
|
||
|
||
document.getElementById('infoClose').addEventListener('click', hideTileInfo);
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') hideTileInfo();
|
||
});
|
||
|
||
function regenerateCurrentTile() {
|
||
if (!currentTile || !apiAvailable) return;
|
||
const t = currentTile;
|
||
hideTileInfo();
|
||
pendingTiles = [[t.col, t.row]];
|
||
pendingRegen = true;
|
||
document.getElementById('genRegen').checked = true;
|
||
genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez';
|
||
genGo.style.display = '';
|
||
genBar.classList.add('show');
|
||
}
|
||
|
||
// ============================================================
|
||
// Génération de nouvelles zones via l'API web (./run.sh --serve)
|
||
// ============================================================
|
||
let apiAvailable = false;
|
||
const genBtn = document.getElementById('btnGenZone');
|
||
const genBar = document.getElementById('genBar');
|
||
const genMsg = document.getElementById('genMsg');
|
||
const genGo = document.getElementById('genGo');
|
||
const genCancel = document.getElementById('genCancel');
|
||
const genRegen = document.getElementById('genRegen');
|
||
const genClass = document.getElementById('genClass');
|
||
const genIgnClasses = document.getElementById('genIgnClasses');
|
||
const genViz = document.getElementById('genViz');
|
||
const genBareEarth = document.getElementById('genBareEarth');
|
||
const genQueue = document.getElementById('genQueue');
|
||
const genQueueBody = document.getElementById('genQueueBody');
|
||
const genQueueClear = document.getElementById('genQueueClear');
|
||
const genQueueCount = document.getElementById('genQueueCount');
|
||
let genMode = false, drawStart = null, drawRect = null;
|
||
let pendingTiles = [], pendingRegen = false;
|
||
const ghostCells = [];
|
||
|
||
fetch('/api/status').then(r => {
|
||
apiAvailable = r.ok;
|
||
if (r.ok) genBtn.hidden = false;
|
||
}).catch(() => {});
|
||
|
||
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
|
||
|
||
function exitGenMode() {
|
||
genMode = false; drawStart = null;
|
||
document.body.classList.remove('drawing');
|
||
if (drawRect) { drawRect.remove(); drawRect = null; }
|
||
map.dragging.enable();
|
||
}
|
||
|
||
genBtn.addEventListener('click', () => {
|
||
if (genMode) { exitGenMode(); return; }
|
||
clearGhosts();
|
||
genBar.classList.remove('show');
|
||
genMode = true;
|
||
map.dragging.disable();
|
||
document.body.classList.add('drawing');
|
||
genMsg.textContent = 'Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)';
|
||
genGo.style.display = 'none';
|
||
genBar.classList.add('show');
|
||
});
|
||
|
||
map.on('mousedown', e => {
|
||
if (!genMode) return;
|
||
drawStart = e.latlng;
|
||
if (drawRect) drawRect.remove();
|
||
drawRect = L.rectangle([drawStart, drawStart],
|
||
{ color: '#4cc9f0', weight: 2, dashArray: '6 4', fillOpacity: 0.05 }).addTo(map);
|
||
});
|
||
map.on('mousemove', e => {
|
||
if (!genMode || !drawStart || !drawRect) return;
|
||
drawRect.setBounds(L.latLngBounds(drawStart, e.latlng));
|
||
});
|
||
map.on('mouseup', e => {
|
||
if (!genMode || !drawStart) return;
|
||
const b = L.latLngBounds(drawStart, e.latlng);
|
||
drawStart = null;
|
||
if (Math.abs(b.getWest() - b.getEast()) < 1e-9 || Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
|
||
exitGenMode();
|
||
genMsg.textContent = 'Analyse de la zone...';
|
||
genGo.style.display = 'none';
|
||
genBar.classList.add('show');
|
||
fetch('/api/preview', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
|
||
regenerate: genRegen.checked })
|
||
}).then(r => r.json()).then(d => {
|
||
if (drawRect) { drawRect.remove(); drawRect = null; }
|
||
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
|
||
(d.cells || []).forEach(c => ghostCells.push(
|
||
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
|
||
));
|
||
if (!pendingTiles.length) {
|
||
genMsg.textContent = 'Aucune tuile manquante dans cette zone' +
|
||
(genRegen.checked ? '' : ' (cochez « Régénérer » pour traiter les tuiles existantes)');
|
||
genGo.style.display = 'none';
|
||
return;
|
||
}
|
||
genMsg.textContent = pendingTiles.length + ' tuile(s) à ' +
|
||
(genRegen.checked ? 'régénérer' : 'télécharger et générer') +
|
||
(d.capped ? ' (zone tronquée à 400)' : '');
|
||
genGo.style.display = '';
|
||
}).catch(() => { genMsg.textContent = 'API indisponible'; });
|
||
});
|
||
|
||
genGo.addEventListener('click', () => {
|
||
if (!pendingTiles.length) return;
|
||
genBar.classList.remove('show');
|
||
clearGhosts();
|
||
const nTiles = pendingTiles.length;
|
||
const isRegen = genRegen.checked || pendingRegen;
|
||
const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
||
fetch('/api/generate', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tiles: pendingTiles,
|
||
regenerate: isRegen,
|
||
viz: vizSel.length ? vizSel : ['aspect'],
|
||
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(() => startGenTask(nTiles + ' tuile(s) — ' + (isRegen ? 'régénération' : 'génération')))
|
||
.catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); });
|
||
});
|
||
|
||
genCancel.addEventListener('click', () => {
|
||
genBar.classList.remove('show');
|
||
clearGhosts();
|
||
pendingTiles = []; pendingRegen = false;
|
||
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.
|
||
// ============================================================
|
||
const GEN_QKEY = 'lidarGenQueue';
|
||
let genTasks = [];
|
||
try { genTasks = JSON.parse(sessionStorage.getItem(GEN_QKEY) || '[]'); } catch (e) { genTasks = []; }
|
||
let genPollTimer = null;
|
||
|
||
function saveGenTasks() {
|
||
try { sessionStorage.setItem(GEN_QKEY, JSON.stringify(genTasks)); } catch (e) { /* privé */ }
|
||
}
|
||
|
||
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';
|
||
state.textContent = t.state === 'running' ? 'En cours…'
|
||
: t.state === 'done' ? 'Terminé'
|
||
: 'É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';
|
||
head.append(dot, label, state, time, toggle);
|
||
el.appendChild(head);
|
||
const log = document.createElement('pre');
|
||
log.className = 'gen-task-log';
|
||
log.hidden = !t.logOpen;
|
||
log.textContent = (t.log || []).join('\n') || 'Démarrage...';
|
||
log.scrollTop = log.scrollHeight;
|
||
el.appendChild(log);
|
||
toggle.addEventListener('click', () => {
|
||
t.logOpen = !t.logOpen; saveGenTasks(); renderGenQueue();
|
||
});
|
||
return el;
|
||
}
|
||
|
||
function renderGenQueue() {
|
||
genQueueBody.textContent = '';
|
||
// Plus récentes en haut
|
||
for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t));
|
||
const running = genTasks.filter(t => t.state === 'running').length;
|
||
genQueueCount.textContent = genTasks.length
|
||
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
|
||
: '';
|
||
genQueue.hidden = genTasks.length === 0;
|
||
}
|
||
|
||
function startGenTask(labelText) {
|
||
const now = new Date();
|
||
genTasks.push({
|
||
id: Date.now(), label: labelText,
|
||
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||
state: 'running', returncode: null, log: [], logOpen: false,
|
||
});
|
||
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();
|
||
}
|
||
|
||
function pollGenStatus() {
|
||
if (genPollTimer) return; // un seul sondage global pour toute la file
|
||
genPollTimer = setInterval(() => {
|
||
fetch('/api/status').then(r => r.json()).then(d => {
|
||
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);
|
||
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 */ }
|
||
setTimeout(() => location.reload(), 2500);
|
||
} else {
|
||
saveGenTasks(); renderGenQueue();
|
||
}
|
||
}).catch(() => {});
|
||
}, 2000);
|
||
}
|
||
|
||
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,
|
||
// ou adopte une génération lancée hors interface (API directe) pour
|
||
// l'afficher dans la file avec son journal.
|
||
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
|
||
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
|
||
if (!s || !s.running) return;
|
||
genTasks.push({
|
||
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
|
||
time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||
state: 'running', returncode: null, log: [], logOpen: false,
|
||
});
|
||
saveGenTasks(); renderGenQueue(); pollGenStatus();
|
||
}).catch(() => {});
|
||
|
||
// ============================================================
|
||
// Initialisation
|
||
// ============================================================
|
||
renderOsmRow();
|
||
renderLayerPanel();
|
||
syncTileLayers();
|
||
fitView();
|
||
layoutTiles();
|
||
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
||
|
||
// ============================================================
|
||
// Détection de nouvelles couches (run de génération en cours)
|
||
// ============================================================
|
||
// Le panneau et les URLs par tuile sont figés au rebuild de l'index :
|
||
// /api/layers liste ce qui existe sur disque, et le bouton ↻ reconstruit
|
||
// la carte (vignettes + sous-tuiles) puis recharge la page.
|
||
const layerRefreshBtn = document.getElementById('layerRefresh');
|
||
function checkNewLayers() {
|
||
fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => {
|
||
if (!d || !layerRefreshBtn) return;
|
||
const news = Object.keys(d).filter(k => !VIZ_META[k]);
|
||
layerRefreshBtn.hidden = news.length === 0;
|
||
if (news.length) {
|
||
layerRefreshBtn.title = 'Nouvelles couches : ' +
|
||
news.map(k => d[k]).join(', ') + ' — cliquer pour actualiser la carte';
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
checkNewLayers();
|
||
setInterval(checkNewLayers, 60000);
|
||
if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
|
||
layerRefreshBtn.textContent = '…';
|
||
layerRefreshBtn.disabled = true;
|
||
fetch('/api/rebuild', { method: 'POST' })
|
||
.then(r => r.ok ? r.json() : Promise.reject('rejeté'))
|
||
.then(() => new Promise(resolve => {
|
||
const poll = setInterval(() => {
|
||
fetch('/api/rebuild').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 rebuild : ' + err;
|
||
} else {
|
||
location.reload();
|
||
}
|
||
})
|
||
.catch(() => {
|
||
layerRefreshBtn.textContent = '↻';
|
||
layerRefreshBtn.disabled = false;
|
||
});
|
||
});
|
||
|
||
} // fin guard Leaflet
|
||
"""
|