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.
2660 lines
105 KiB
Python
2660 lines
105 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',
|
||
'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
|
||
"""
|