"""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. - Onglet Export : sélection de dalles sur la carte → mosaïque jointive (image ou PDF multi-couches) téléchargeable pour consultation sur téléphone (/api/export de la webapp, module export.py). 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', } # Légendes des couches : titre, lecture du rendu (couleurs), méthode de calcul # et dégradé de colormap. Source unique partagée entre rendering.py (images # unitaires) et export.py (bandeau légende des mosaïques multi-dalles) — ce # module reste volontairement sans dépendance lourde (webapp légère : ni # matplotlib, ni GDAL). # 'gradient' : 9 arrêts échantillonnés sur la colormap matplotlib (plt.get_cmap # à i/8) pour dessiner une barre de dégradé sans matplotlib ; la cohérence # avec rendering.COLORMAPS est vérifiée par les tests (test_export.py). # 'ticks' : libellés des extrémités du dégradé (None = pas de barre). VIZ_LEGENDS = { 'hillshade_multi': { 'title': 'Hillshade Multidirectionnel', 'legend': 'Illumination combinée de 8 directions (échelle fixe 0–1)\nBlanc = Face éclairée | Noir = Zone d\'ombre\nCouleurs homogènes entre tuiles', 'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)', 'cmap': 'gray', 'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080', '#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'), 'ticks': ('0', '1'), }, 'slope': { 'title': 'Pente (Inclinaison du terrain)', 'legend': 'Inclinaison en degrés\nÉchelle fixe 0–30° — couleurs homogènes entre tuiles\nJaune = Forte pente | Violet foncé = Terrain plat', 'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre', 'cmap': 'inferno', 'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754', '#e45a31', '#f98e09', '#f9cb35', '#fcffa4'), 'ticks': ('0°', '30°'), }, 'aspect': { 'title': 'Aspect (Direction des pentes)', 'legend': 'Direction vers laquelle le terrain descend\nCycle continu : Nord→Est→Sud→Ouest→Nord\nCouleurs perceptuellement uniformes (pas de saut de teinte)', 'description': 'Orientation des pentes — utile pour distinguer structures des formes naturelles', 'cmap': 'twilight', 'gradient': ('#e2d9e2', '#95b5c7', '#6276ba', '#592a8f', '#2f1436', '#741e4f', '#b25652', '#cca389', '#e2d9e2'), 'ticks': ('Nord 0°', 'Nord 360°'), }, 'mslrm': { 'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)', 'legend': 'Relief combiné multi-échelles (σ locales, échelle fixe ±3σ)\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nÉchelles : 2 à 200m, pondérées 5–20m\nCouleurs homogènes entre tuiles\nDétecte du micro au macro', 'description': 'Combine LRM à 5–7 échelles — détecte structures de 5m à 100m simultanément', 'cmap': 'seismic', 'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd', '#ff7d7d', '#fe0000', '#be0000', '#800000'), 'ticks': ('-3σ', '+3σ'), }, 'sailore': { 'title': 'SAILORE - LRM Auto-Adaptatif', 'legend': 'Relief local adaptatif (σ locales, échelle fixe ±3σ)\nRouge = Surélévation | Bleu = Dépression\nCouleurs homogènes entre tuiles\n\nNoyau adapté à la pente locale\nPlat = grand noyau (25m) | Pente = petit noyau (2m)', 'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau', 'cmap': 'seismic', 'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd', '#ff7d7d', '#fe0000', '#be0000', '#800000'), 'ticks': ('-3σ', '+3σ'), }, 'positive_openness': { 'title': 'Openness Positive (ouverture vers le haut)', 'legend': 'Angle d\'ouverture vers le ciel (z-score local)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles', 'description': 'Ray-tracing 8 directions, multi-rayon — détecte crêtes et sommets', 'cmap': 'YlOrBr', 'gradient': ('#ffffe5', '#fff7bc', '#fee390', '#fec34f', '#fe9829', '#eb6f14', '#cb4b02', '#983404', '#662506'), 'ticks': ('-3σ', '+3σ'), }, 'negative_openness': { 'title': 'Openness Negative (ouverture vers le bas)', 'legend': 'Angle d\'ouverture vers le bas (z-score local)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nMeilleur détecteur de cavités et dolines', 'description': 'Ray-tracing 8 directions, multi-rayon — détecte fossés, dolines, souterrains', 'cmap': 'PuBu', 'gradient': ('#fff7fb', '#ece7f2', '#d0d1e6', '#a5bddb', '#73a9cf', '#358fc0', '#056faf', '#04598c', '#023858'), 'ticks': ('-3σ', '+3σ'), }, 'svf': { 'title': 'Sky-View Factor (fraction de ciel visible)', 'legend': 'Proportion de ciel visible (échelle physique fixe 0–1)\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nCouleurs homogènes entre tuiles\nLes fossés ressortent en vif — excellent pour structures linéaires', 'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre', 'cmap': 'hot_r', 'gradient': ('#ffffff', '#ffff81', '#ffff03', '#ffad00', '#ff5900', '#ff0500', '#b00000', '#5c0000', '#0b0000'), 'ticks': ('0', '1'), }, 'roughness': { 'title': 'Rugosité Multi-Échelle (3m + 15m)', 'legend': 'Irrégularité du terrain combinée fine + large\nViolet foncé = Surface lisse (route, mur, sol plat)\nJaune vif = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)', 'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses', 'cmap': 'plasma', 'gradient': ('#0d0887', '#4c02a1', '#7e03a8', '#aa2395', '#cc4778', '#e66c5c', '#f89540', '#fdc527', '#f0f921'), 'ticks': ('lisse', 'rugueux'), }, 'wavelet': { 'title': 'Ondelette Mexican Hat (CWT multi-échelle)', 'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts', 'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)', 'cmap': 'inferno', 'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754', '#e45a31', '#f98e09', '#f9cb35', '#fcffa4'), 'ticks': ('bruit', 'structure'), }, 'flow_acc': { 'title': 'Accumulation d\'Écoulement (Flow Accumulation)', 'legend': 'Log10 du nombre de cellules amont\nVert foncé = Forte accumulation (fossé, chenal, drainage)\nJaune = Faible accumulation (terrain plat)\n\nDétection fossés et linéaires hydrologiques', 'description': 'Priority-flood + D8 — détecte fossés archéologiques et drainages', 'cmap': 'YlGn', 'gradient': ('#ffffe5', '#f7fcb9', '#d9f0a3', '#acdd8e', '#77c679', '#40aa5c', '#228343', '#006737', '#004529'), 'ticks': ('faible', 'forte'), }, 'solar': { 'title': 'Éclairage Solaire', 'legend': "Illumination solaire (azimut 90°, altitude 30°)\nClair = Face éclairée | Sombre = Zone d'ombre", 'description': "Simulation de l'éclairage solaire matinal", 'cmap': 'gray', 'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080', '#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'), 'ticks': ('0', '1'), }, 'anomaly': { 'title': 'Carte d\'Anomalies (détection automatique)', 'legend': 'Score d\'anomalie composite (0–1)\nRouge = Haute anomalie (structures suspectes)\nJaune = Anomalie modérée\nBlanc = Aucun signal (terrain naturel)\n\nSeuil auto: pixels > 2σ de la moyenne locale\nCombiné: MSRM, SVF, Ondelette, Openness, Rugosité', 'description': 'Détection automatique — cible à vérifier sur terrain', 'cmap': 'YlOrRd', 'gradient': ('#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8c3c', '#fc4d2a', '#e2191c', '#bb0026', '#800026'), 'ticks': ('0', '1'), }, 'ortho': { 'title': 'Photographie Aérienne IGN', 'legend': 'Orthophotographie\nImage aérienne', 'description': 'Photographie aérienne IGN (Orthophoto)', 'cmap': None, 'gradient': None, 'ticks': None, }, 'topo': { 'title': 'Carte Topographique IGN', 'legend': 'Carte IGN\nPlan topographique', 'description': 'Carte topographique IGN (Plan IGN)', 'cmap': None, 'gradient': None, 'ticks': None, }, } # Couches activées par défaut à l'ouverture de la carte : seules celles-ci # sont allumées, toutes les autres sont désactivées (mais restent accessibles # via le panneau). Ordre = pile basse → haute. DEFAULT_LAYERS = ('slope', 'aspect', 'positive_openness', 'roughness') # Couche « principale » (mise en avant ; index_tiles.json + affichage en # direct de la webapp). DEFAULT_VIZ = DEFAULT_LAYERS[0] # Restriction du panneau de couches : seule la sélection DEFAULT_LAYERS est # proposée (couches conservées ; les autres ne sont plus ni affichées ni # référencées). None = toutes les visualisations présentes sur disque. # Le sélecteur de génération/régénération est piloté par le même registre # (voir _render_html). PANEL_VIZ = DEFAULT_LAYERS # 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 default_layers_present(all_viz_keys): """Couches activées par défaut (DEFAULT_LAYERS) présentes sur le disque. Ordre conservé (DEFAULT_LAYERS) ; sert de base à l'état initial du panneau (toutes les autres couches sont désactivées). """ present = set(all_viz_keys) return [v for v in DEFAULT_LAYERS if v in present] 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)} # Toutes les visualisations sont découpées en sous-tuiles (quadrants 500 m) # pour alléger la carte. Renseigner un tuple pour restreindre la découpe — # les viz exclues retombent en repli dalle entière. _CARTO_SUBTILED_VIZ = () # 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 # Couches à contenu photographique ou traits fins (orthophoto, carte topo) : # q55, calé sur des rampes de couleur, bave textes et linéaires — qualité # relevée pour ces couches uniquement. _SUBTILE_AVIF_QUALITY_DETAIL = 75 _SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'}) # 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 (0,5 m = résolution primaire, sans suffixe). Même convention que pipeline.LidarArchaeoPipeline._res_suffix, mais réimplémentée localement : importer le pipeline tirerait dtm→numpy, absent de l'image webapp légère (Dockerfile.webapp) et cassait build_index. Toute évolution du format doit rester synchronisée avec pipeline.py (_res_suffix) et le décodage inverse (_strip_res_suffix). """ if resolution == 0.5: return "" return f"_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 _url_version(mtime): """Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu. Les images de la carte sont servies par des montages statiques sans Cache-Control : après une régénération, le cache heuristique du navigateur peut continuer d'afficher l'ancien rendu (même URL = image considérée fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un recalcul change l'URL et force le rechargement — y compris en direct pendant un run : pollLiveTiles fusionne les nouvelles données et updateImages() voit un src différent. """ return f"?v={int(mtime * 1000)}" if mtime is not None else "" 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 _fallback_full_dalle(entries, viz_key, info): """Repli dalle entière pour une couche non découpable : elle reste superposable en couche (images plus lourdes, mais fonctionnelles).""" for entry in entries.values(): entry['viz'][viz_key] = dict(info) 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 avif_quality = (_SUBTILE_AVIF_QUALITY_DETAIL if viz_key in _SUBTILE_DETAIL_VIZ else _SUBTILE_AVIF_QUALITY) 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). L'URL pleine # porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin. src = output_dir / info['full'].split('?')[0] 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}") _fallback_full_dalle(entries, viz_key, info) continue if img.mode not in ('RGB', 'L'): img = img.convert('RGB') W, H = img.size cut_failed = False 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=avif_quality, speed=_SUBTILE_AVIF_SPEED) except Exception as e: logger.warning(f"Encodage AVIF impossible ({stem}), " f"repli dalle entière pour {viz_key} : {e}") cut_failed = True break # 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) if cut_failed: _fallback_full_dalle(entries, viz_key, info) continue v = _url_version(src_mtime) for (i, j), stem in stems.items(): entries[(i, j)]['viz'][viz_key] = { 'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}", 'mid': f"{sub_dir_name}/{stem}_mid.webp{v}", 'full': f"{sub_dir_name}/{stem}.avif{v}", } 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 hors sélection : elles # restent superposables en couche (images plus lourdes, mais fonctionnelles). sub_keys = set(offered_viz_keys) for viz_key, info in tile['viz'].items(): if viz_key not in sub_keys: _fallback_full_dalle(entries, viz_key, 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 v = _url_version(src_mtime) viz_thumbs[viz_key] = { 'thumb': f"index_thumbs/{thumb_name}{v}", 'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}", } if mid_path.is_file(): viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}{v}" 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, 'default_layers': default_layers_present(all_viz_keys), '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). """ # Panneau complet : toutes les couches du registre (VIZ_LABELS), même # absentes du disque — activables dès leur apparition — plus les couches # historiques éventuellement trouvées sur disque (libellé = clé brute). # PANEL_VIZ permet toujours de restreindre volontairement la sélection. panel_keys = set(VIZ_LABELS) | 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 puis aspect, à condition # d'exister sur le disque ; sinon la 1re proposée présente, sinon la 1re. default_viz = next( (v for v in (DEFAULT_VIZ, 'aspect') if v in all_viz_keys), next((v for v in ordered_viz if v in all_viz_keys), 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 : les couches du panneau # (PANEL_VIZ si restreint, sinon le registre complet — mêmes libellés, # valeurs = noms d'étapes --only du pipeline), y compris celles absentes # du disque — pour pouvoir les générer. Toutes pré-sélectionnées : une # régénération refait la dalle complète, l'utilisateur pouvant # désélectionner pour cibler. gen_base = PANEL_VIZ if PANEL_VIZ is not None else VIZ_LABELS gen_keys = [v for v in _VIZ_FALLBACK_ORDER if v in gen_base] gen_keys += sorted(k for k in gen_base if k not in gen_keys) gen_viz_options = "\n".join( f' ' 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, 'default_layers': default_layers_present(all_viz_keys), '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 = """ Carte LiDAR
Zoom
Molette : zoom · Clic-glisser : déplacer
Clic sur tuile : infos de génération
""" _APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */ :root { /* Le color-scheme impose au navigateur le rendu sombre des contrôles natifs (popup des ' + '' + Math.round(state.layers.opacity[key] * 100) + '%'; 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'; // ============================================================ // Préconfigurations de couches : un preset définit les couches // visibles, leurs niveaux de transparence (opacités des // visualisations + fond de carte OSM) et l'ordre de pile // (bas → haut). Les presets intégrés couvrent les couches du // panneau ; les presets personnels sont stockés sur le serveur // (/api/presets, partagés entre tous les navigateurs connectés) // et en écho dans le localStorage du navigateur (repli si l'interface // est servie sans webapp). // ============================================================ const PRESETS_LS = 'lidarPresets_v1'; const PRESETS_URL = '/api/presets'; // Écho local des presets : s'il n'y a pas de webapp (serveur statique), // ils ne vivent que dans le localStorage du navigateur. let customPresets = (() => { try { const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]'); return Array.isArray(p) ? p : []; } catch (e) { return []; } })(); function persistCustomPresets() { try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ } } // Les presets stockés sur le serveur sont partagés entre tous les // navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux // déjà en local (même id = la version serveur l'emporte). Webapp // injoignable : la liste locale est conservée telle quelle. function upsertCustomPreset(p) { const i = customPresets.findIndex(x => x.id === p.id); if (i === -1) customPresets.push(p); else customPresets[i] = p; } function removeCustomPreset(id) { customPresets = customPresets.filter(p => p.id !== id); } async function syncCustomPresets() { try { const r = await fetch(PRESETS_URL, { cache: 'no-store' }); if (!r.ok) return; const data = await r.json(); const server = Array.isArray(data.presets) ? data.presets : []; const byId = new Map(customPresets.map(p => [p.id, p])); for (const p of server) if (p && p.id) byId.set(p.id, p); customPresets = [...byId.values()]; persistCustomPresets(); renderPresetSelect(presetSelectEl.value); } catch (e) { /* pas de webapp : presets locaux uniquement */ } } // Les presets portent les niveaux de transparence : opacité de chaque // visualisation + fond de carte OSM (visibilité, opacité, mode sombre). const BUILTIN_PRESETS = [ { label: 'Relief', on: ['aspect', 'positive_openness'], opacity: { aspect: 1, positive_openness: 0.65 }, osm: { on: false } }, { label: 'Pentes', on: ['aspect', 'slope'], opacity: { aspect: 0.5, slope: 1 }, osm: { on: true, opacity: 0.3 } }, { label: 'Cavités', on: ['slope', 'positive_openness'], opacity: { slope: 0.5, positive_openness: 1 }, osm: { on: false } }, { label: 'Rugosité', on: ['roughness'], opacity: {}, osm: { on: true, opacity: 0.3 } }, { label: 'Tout', on: ['slope', 'aspect', 'positive_openness', 'roughness'], opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55, roughness: 0.8 }, osm: { on: true, opacity: 0.85, dark: true } }, ]; // Niveaux de transparence d'un preset, lisibles dans la liste // (opacité de chaque visualisation + fond de carte OSM). function presetDetail(p) { const parts = (p.on || []).filter(k => VIZ_META[k]) .map(k => (VIZ_META[k].label || k) + ' ' + Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%'); if (p.osm) parts.push('Fond ' + (p.osm.on ? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%' : 'masqué')); return parts.join(' · '); } function applyPreset(p) { const want = (p.on || []).filter(k => VIZ_META[k]); const L = state.layers; const off = L.order.filter(k => !want.includes(k)); L.order = want.slice(); // ordre du preset (bas → haut) L.order.push(...off); // les autres gardent leur ordre relatif for (const k of L.order) { const on = want.includes(k); L.on[k] = on; if (on) L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1; } // Niveaux de transparence du fond de carte (visibilité, opacité, sombre) if (p.osm) { L.osm.on = !!p.osm.on; if (p.osm.opacity != null) L.osm.opacity = Number(p.osm.opacity); if (p.osm.dark != null) L.osm.dark = !!p.osm.dark; } saveLayers(); renderLayerPanel(); renderOsmRow(); syncTileLayers(); } const presetSelectEl = document.getElementById('presetSelect'); const presetSaveEl = document.getElementById('presetSave'); const presetDeleteEl = document.getElementById('presetDelete'); function renderPresetSelect(selectedValue) { presetSelectEl.innerHTML = ''; const none = document.createElement('option'); none.value = ''; none.textContent = 'Préconfigurations…'; presetSelectEl.appendChild(none); for (const p of BUILTIN_PRESETS) { const o = document.createElement('option'); o.value = 'builtin:' + p.label; o.textContent = p.label + ' — ' + presetDetail(p); presetSelectEl.appendChild(o); } const customs = customPresets; if (customs.length) { const g = document.createElement('optgroup'); g.label = 'Mes presets'; for (const p of customs) { const o = document.createElement('option'); o.value = 'custom:' + p.id; o.textContent = p.label + ' — ' + presetDetail(p); g.appendChild(o); } presetSelectEl.appendChild(g); } if (selectedValue) presetSelectEl.value = selectedValue; presetDeleteEl.hidden = !presetSelectEl.value.startsWith('custom:'); } presetSelectEl.addEventListener('change', () => { const v = presetSelectEl.value; if (!v) return; let p = null; if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8)); else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7)); if (p) applyPreset(p); }); presetSaveEl.addEventListener('click', () => { const name = prompt('Nom du preset :'); if (!name || !name.trim()) return; const enabled = state.layers.order.filter(k => state.layers.on[k]); const osm = state.layers.osm; const p = { id: Date.now().toString(36), label: name.trim(), on: enabled, opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])), // Niveaux de transparence du fond de carte osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark }, }; upsertCustomPreset(p); persistCustomPresets(); renderPresetSelect('custom:' + p.id); // Persistance serveur (partagée entre navigateurs) ; en échec // (pas de webapp), le preset reste dans le stockage local. fetch(PRESETS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p), }).then(r => { if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status); }).catch(() => {}); }); presetDeleteEl.addEventListener('click', () => { const v = presetSelectEl.value; if (!v.startsWith('custom:')) return; if (!confirm('Supprimer ce preset ?')) return; const id = v.slice(7); removeCustomPreset(id); persistCustomPresets(); renderPresetSelect(''); fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' }) .catch(() => {}); }); renderPresetSelect(); syncCustomPresets(); 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 et partage. // Hash simple : #zoom,lat,lng. Hash partagé (bouton « Partager ») : // #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark — L liste // TOUTES les couches de bas en haut (opacité en %, visible 0/1), O // décrit le fond OSM (affiché : opacité en % : mode sombre). // ============================================================ function parseSharedHash() { const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)(.*)$/.exec(location.hash || ''); if (!m || isNaN(parseFloat(m[1])) || isNaN(parseFloat(m[2])) || isNaN(parseFloat(m[3]))) return null; const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]), layers: null, osm: null }; const seg = {}; for (const p of m[4].split('&')) { const i = p.indexOf('='); if (i > 0) seg[p.slice(0, i)] = p.slice(i + 1); } if (seg.L) { const found = {}, order = []; for (const part of seg.L.split(',')) { const f = part.split(':'); if (f.length < 3 || !VIZ_META[f[0]]) continue; const op = Number(f[1]); if (isNaN(op)) continue; found[f[0]] = { on: f[2] === '1', op: Math.min(100, Math.max(0, op)) }; order.push(f[0]); } if (order.length) { for (const k of Object.keys(VIZ_META)) if (!order.includes(k)) order.push(k); const on = {}, opacity = {}; for (const k of order) { const e = found[k]; on[k] = e ? e.on : false; opacity[k] = e ? e.op / 100 : 1; } out.layers = { order, on, opacity }; } } if (seg.O) { const f = seg.O.split(':'); if (f.length === 3) { const op = Number(f[1]); if (!isNaN(op)) { out.osm = { on: f[0] === '1', opacity: Math.min(100, Math.max(0, op)) / 100, dark: f[2] === '1' }; } } } return out; } function readViewHash() { return parseSharedHash(); } function saveViewHash() { const c = map.getCenter(); history.replaceState(null, '', '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6)); } // Construit le lien partageable : vue courante + état complet des couches // (pile, visibilité, opacités) + fond OSM. function buildShareHash() { const c = map.getCenter(); const L = state.layers.order.map(k => k + ':' + Math.round(state.layers.opacity[k] * 100) + ':' + (state.layers.on[k] ? '1' : '0') ).join(','); const o = state.layers.osm; return '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6) + '&L=' + L + '&O=' + (o.on ? '1' : '0') + ':' + Math.round(o.opacity * 100) + ':' + (o.dark ? '1' : '0'); } // Copie du lien : l'API presse-papiers n'existe qu'en contexte sécurisé // (https/localhost) — sur http:// on repasse par // execCommand, puis par un prompt de repli. const shareBtn = document.getElementById('btnShare'); function copyShareLink() { const url = location.href; const feedback = () => { if (!shareBtn) return; shareBtn.textContent = 'Lien copié ✓'; shareBtn.disabled = true; setTimeout(() => { shareBtn.textContent = '⎘ Partager'; shareBtn.disabled = false; }, 2000); }; const legacy = () => { const inp = document.createElement('input'); inp.value = url; inp.style.cssText = 'position:fixed;left:-9999px;top:0'; document.body.appendChild(inp); inp.select(); inp.setSelectionRange(0, url.length); let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } inp.remove(); if (ok) feedback(); else window.prompt('Copier le lien de la vue :', url); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(feedback, legacy); } else legacy(); } if (shareBtn) shareBtn.addEventListener('click', () => { history.replaceState(null, '', location.pathname + location.search + buildShareHash()); copyShareLink(); }); 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] }); }); // ============================================================ // Position GPS du téléphone : centrer la carte dessus // ============================================================ const gpsBtn = document.getElementById('btnGps'); const gpsToast = document.getElementById('gpsToast'); let gpsToastTimer = null; let gpsMarker = null; function gpsMsg(text, ms) { gpsToast.textContent = text; gpsToast.hidden = false; clearTimeout(gpsToastTimer); gpsToastTimer = setTimeout(() => { gpsToast.hidden = true; }, ms || 4000); } gpsBtn.addEventListener('click', () => { if (!('geolocation' in navigator)) { gpsMsg('Géolocalisation non prise en charge par ce navigateur ' + '(connexion sécurisée HTTPS requise).', 6000); return; } gpsBtn.classList.add('busy'); try { navigator.geolocation.getCurrentPosition(pos => { gpsBtn.classList.remove('busy'); const ll = [pos.coords.latitude, pos.coords.longitude]; map.setView(ll, Math.max(map.getZoom(), 16)); if (gpsMarker) gpsMarker.remove(); gpsMarker = L.circleMarker(ll, { radius: 5, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9 }).addTo(map); if (pos.coords.accuracy) { gpsMarker.bindTooltip('Précision GPS ~' + Math.round(pos.coords.accuracy) + ' m'); } gpsMsg('Carte centrée sur votre position GPS.'); }, err => { gpsBtn.classList.remove('busy'); let why = 'Aucune position reçue'; if (err && err.code === 1) why = 'Autorisation de localisation refusée'; else if (err && err.code === 2) why = 'Position momentanément indisponible'; else if (err && err.code === 3) why = 'Délai de localisation dépassé'; gpsMsg(why + '.', 6000); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }); } catch (e) { gpsBtn.classList.remove('busy'); gpsMsg('La géolocalisation exige une connexion sécurisée (HTTPS).', 6000); } }); // 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 = '
' + 'Tuile ' + t.col + ' · ' + t.row + '' + '' + res + ' m/px' + '' + '
' + '
' + t.name + '
' + '
' + (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km · ' + methodLabel(meta.method) + (meta.generated ? ' · généré le ' + fmtShortDate(meta.generated) : '') + '
' + '
Visualisations
' + '
' + '
' + '' + '
'; 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 = '' + '' + '⤓'; 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(); if (exportMode) setExportMode(false); if (genSelMode) setGenSelMode(false); } }); 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'); updateGenClassInfo(); } // ============================================================ // 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 btnGenSel = document.getElementById('btnGenSel'); 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 genReclass = document.getElementById('genReclass'); const genClassInfo = document.getElementById('genClassInfo'); 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 genStop = document.getElementById('genStop'); 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 = []; let genSelMode = false; const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération // 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'); } // Classification déjà présente sur les tuiles visées : guide le choix // « Reclasser le sol » (décoché = la classification existante est conservée). function updateGenClassInfo() { if (!genClassInfo) return; const methods = new Set(); for (const pair of (pendingTiles || [])) { for (const t of TILES) { if (t.col === pair[0] && t.row === pair[1] && t.meta && t.meta.method) methods.add(t.meta.method); } } if (!methods.size) { genClassInfo.hidden = true; return; } genClassInfo.textContent = 'Classification existante : ' + [...methods].map(methodLabel).join(', ') + ' — conservée si « Reclasser le sol » est décoché et que la méthode ne change pas'; genClassInfo.hidden = false; } fetch('/api/status').then(r => { apiAvailable = r.ok; if (!apiAvailable) hideExportTab(); return r.ok ? r.json() : null; }).then(d => { const genBtns = [genBtn, btnGenSel, btnComplete]; if (!d) { if (apiAvailable) genBtns.forEach(b => { if (b) b.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) genBtns.forEach(b => { if (b) b.hidden = !regenAllowed; }); }).catch(() => { if (!apiAvailable) hideExportTab(); }); 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(); clearGenSel(); 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é updateGenClassInfo(); (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'); }); }); // « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une // dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues // (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de // confirmation permet ensuite de choisir les couches et les options avant de // lancer la régénération (--force --force-classification) sur la sélection. function genSelKey(col, row) { return col + ',' + row; } function refreshGenSelHighlights() { for (const entry of markers.values()) { const div = entryDiv(entry); if (!div) continue; div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row))); } } function genSelCells() { return [...genSel].map(k => k.split(',').map(Number)); } function updateGenSelBar() { showGenBar(genSel.size ? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer' : 'Mode sélection : clic sur dalle = ajouter / retirer'); genGo.style.display = genSel.size ? '' : 'none'; } function toggleGenSel(t) { const key = genSelKey(t.col, t.row); if (genSel.has(key)) genSel.delete(key); else genSel.add(key); refreshGenSelHighlights(); pendingTiles = genSelCells(); pendingRegen = true; pendingCompleteAll = false; updateGenSelBar(); updateGenClassInfo(); } function setGenSelMode(on) { genSelMode = on; document.body.classList.toggle('genSel', on); if (btnGenSel) { btnGenSel.classList.toggle('primary', on); btnGenSel.innerHTML = on ? '✓ Sélection en cours — cliquer des dalles' : '↻ Sélectionner des tuiles'; } // Mobile : le tiroir latéral couvre la carte — on le referme pour laisser // la place aux clics (l'utilisateur le rouvre via ☰ pour valider). if (on && document.body.classList.contains('menu-open')) { document.body.classList.remove('menu-open'); } if (hintChip) { hintChip.innerHTML = on ? 'Mode sélection : clic sur dalle = ajouter / retirer
Échap pour terminer la sélection' : hintDefaultHtml; } if (on) { if (genMode) exitGenMode(); clearGhosts(); pendingCompleteAll = false; genRegen.checked = true; // régénération ciblée : retraitement forcé switchTab('gen'); updateGenSelBar(); } } // Quitte le mode sélection et vide la sélection (changement de mode). function clearGenSel() { if (genSelMode) setGenSelMode(false); if (genSel.size) { genSel.clear(); refreshGenSelHighlights(); } } if (btnGenSel) btnGenSel.addEventListener('click', () => { setGenSelMode(!genSelMode); }); // « 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(); clearGenSel(); 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; updateGenClassInfo(); (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; const fromSel = genSel.size > 0; 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, reclassify: genReclass ? genReclass.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; pendingRegen = false; if (fromSel) clearGenSel(); 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; updateGenClassInfo(); clearGenSel(); if (genMode) exitGenMode(); }); // ============================================================ // Export : mosaïque de dalles adjacentes (image ou PDF téléphone) // ============================================================ // Mode sélection : le clic sur une dalle l'ajoute ou la retire (surlignage // vert), les 4 sous-tuiles 0,2 m comptant pour une seule dalle. La // validation assemble les images pleine résolution côté serveur // (/api/export) : collage bord à bord, aucun trait entre les dalles. const btnExportSel = document.getElementById('btnExportSel'); const exportClearBtn = document.getElementById('exportClear'); const exportBarEl = document.getElementById('exportBar'); const exportCountEl = document.getElementById('exportCount'); const exportResSel = document.getElementById('exportRes'); const exportVizSel = document.getElementById('exportViz'); const exportFormatSel = document.getElementById('exportFormat'); const exportSizeSel = document.getElementById('exportSize'); const exportGoBtn = document.getElementById('exportGo'); const exportMsgEl = document.getElementById('exportMsg'); const exportDoneEl = document.getElementById('exportDone'); const exportDoneList = document.getElementById('exportDoneList'); const hintChip = document.getElementById('hint'); const hintDefaultHtml = hintChip ? hintChip.innerHTML : ''; let exportMode = false; const exportSel = new Set(); // clés "col,row,résolution" des dalles retenues // L'export passe par l'API : sans serveur (carte statique), l'onglet disparaît. function hideExportTab() { const b = document.querySelector('.tab[data-tab="export"]'); if (b) b.style.display = 'none'; } function exportKey(col, row, res) { return col + ',' + row + ',' + res; } function refreshExportHighlights() { for (const entry of markers.values()) { const div = entryDiv(entry); if (!div) continue; const t = entry.t; div.classList.toggle('exportSel', exportSel.has(exportKey(t.col, t.row, t.resolution))); } } function toggleExportSel(t) { const key = exportKey(t.col, t.row, t.resolution); if (exportSel.has(key)) exportSel.delete(key); else exportSel.add(key); refreshExportHighlights(); updateExportPanel(); } function setExportMode(on) { exportMode = on; document.body.classList.toggle('exporting', on); btnExportSel.classList.toggle('primary', on); btnExportSel.innerHTML = on ? '✓ Sélection en cours — cliquer des dalles' : '⤓ Sélectionner des dalles'; // Mobile : le tiroir latéral couvre la carte — on le referme pour laisser // la place aux clics (l'utilisateur le rouvre via ☰ pour valider l'export). if (on && document.body.classList.contains('menu-open')) { document.body.classList.remove('menu-open'); } if (hintChip) { hintChip.innerHTML = on ? 'Mode export : clic sur dalle = ajouter / retirer
Échap pour terminer la sélection' : hintDefaultHtml; } } // Dalles retenues pour une résolution donnée function exportSelCells(res) { const cells = []; for (const key of exportSel) { const p = key.split(','); if (Number(p[2]) === res) cells.push([Number(p[0]), Number(p[1])]); } return cells; } // Couches communes aux dalles sélectionnées (à la résolution choisie) : // seules celles proposées par le panneau (VIZ_META) sont exportables. function exportCommonViz(res) { let common = null; for (const key of exportSel) { const p = key.split(','); if (Number(p[2]) !== res) continue; const keys = new Set(); for (const t of TILES) { if (t.col === Number(p[0]) && t.row === Number(p[1]) && t.resolution === res) { for (const k in t.viz) if (VIZ_META[k]) keys.add(k); } } common = common === null ? keys : new Set([...common].filter(k => keys.has(k))); } return common || new Set(); } function updateExportResOptions() { const resList = [...new Set([...exportSel].map(k => Number(k.split(',')[2])))].sort(); const prev = exportResSel.value !== '' ? Number(exportResSel.value) : null; exportResSel.innerHTML = ''; for (const r of resList) { const o = document.createElement('option'); o.value = String(r); o.textContent = String(r).replace('.', ',') + ' m/px'; exportResSel.appendChild(o); } // Résolution conservée si encore valide, sinon la plus fine exportResSel.value = (prev !== null && resList.includes(prev)) ? String(prev) : String(resList[resList.length - 1]); } function updateExportVizOptions() { const res = Number(exportResSel.value); const common = [...exportCommonViz(res)].sort((a, b) => layerPos(a) - layerPos(b)); const prev = Array.from(exportVizSel.selectedOptions).map(o => o.value) .filter(k => common.includes(k)); exportVizSel.innerHTML = ''; for (const k of common) { const o = document.createElement('option'); o.value = k; o.textContent = VIZ_META[k] ? VIZ_META[k].label : k; exportVizSel.appendChild(o); } // Défaut : la couche active la plus haute, sinon la première proposée const sel = prev.length ? prev : [activeLayers().filter(k => common.includes(k)).pop() || common[0]].filter(Boolean); [...exportVizSel.options].forEach(o => { o.selected = sel.includes(o.value); }); } function updateExportPanel() { const has = exportSel.size > 0; exportBarEl.classList.toggle('show', has); exportClearBtn.hidden = !has; exportMsgEl.textContent = ''; if (!has) return; updateExportResOptions(); updateExportVizOptions(); updateExportCount(); } function updateExportCount() { const res = Number(exportResSel.value); const n = exportSelCells(res).length; const cols = exportSelCells(res).map(c => c[0]); const rows = exportSelCells(res).map(c => c[1]); let sub = ''; if (n) { sub = ' · X ' + Math.min(...cols) + '–' + (Math.max(...cols) + 1) + ' · Y ' + (Math.min(...rows) - 1) + '–' + Math.max(...rows) + ' km'; } exportCountEl.innerHTML = n + ' dalle' + (n > 1 ? 's' : '') + ' sélectionnée' + (n > 1 ? 's' : '') + ' ' + sub + ''; } btnExportSel.addEventListener('click', () => { setExportMode(!exportMode); if (exportMode) switchTab('export'); }); exportClearBtn.addEventListener('click', () => { exportSel.clear(); refreshExportHighlights(); updateExportPanel(); }); exportResSel.addEventListener('change', () => { updateExportVizOptions(); updateExportCount(); }); // Plusieurs couches sélectionnées : une image par couche (comme le PDF fait // une page par couche) — chaque fichier porte la légende de SA couche. exportGoBtn.addEventListener('click', () => { const res = Number(exportResSel.value); const cells = exportSelCells(res); const viz = Array.from(exportVizSel.selectedOptions).map(o => o.value); if (!cells.length || !viz.length) { exportMsgEl.textContent = 'Sélectionnez au moins une dalle et une visualisation.'; return; } exportGoBtn.disabled = true; exportMsgEl.textContent = 'Assemblage en cours… (' + cells.length + ' dalle' + (cells.length > 1 ? 's' : '') + ')'; fetch('/api/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tiles: cells, viz: viz, resolution: res, format: exportFormatSel.value, max_side: Number(exportSizeSel.value) }) }).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) .then(d => { const files = d.fichiers || []; exportMsgEl.textContent = files.length ? '✓ Export prêt' + (files.length > 1 ? ' — ' + files.length + ' fichiers' : (files[0].pages > 1 ? ' — ' + files[0].pages + ' pages' : '')) : '✓ Export prêt'; files.forEach((f, i) => { const a = document.createElement('a'); a.href = f.url; a.download = f.nom; a.title = 'Télécharger (' + (f.largeur || '?') + '×' + (f.hauteur || '?') + ' px)'; a.textContent = '⤓ ' + f.nom + ' · ' + humanSize(f.taille); exportDoneList.prepend(a); if (i === 0) a.click(); // premier fichier : téléchargement direct }); exportDoneEl.hidden = false; }) .catch(msg => { exportMsgEl.textContent = 'Erreur : ' + msg; }) .finally(() => { exportGoBtn.disabled = false; }); }); // ============================================================ // 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 if (t.state === 'stopped') { state.textContent = 'Interrompu'; } 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) : ''; if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; } 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; // returncode négatif = tué par /api/stop ; null = serveur relancé en plein // run (job disparu) : dans les deux cas la tâche est interrompue, pas échouée. t.state = d.returncode === 0 ? 'done' : (d.returncode === null || d.returncode < 0) ? 'stopped' : '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. Les URLs images portent // un suffixe ?v= (mtime de la source) : une tuile régénérée change d'URL, // l' se recharge et le nouveau rendu remplace l'ancien à l'écran — // cache navigateur compris. let liveTilesStamp = null; // Le panneau est figé au chargement (VIZ_META initial) : le flux /api/tiles // fusionne l'index du worker, qui peut encore proposer des couches retirées // (index obsolète) — on n'ajoute au menu que des clés déjà connues. const PANEL_KEYS = new Set(Object.keys(VIZ_META)); 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 (!PANEL_KEYS.has(k) || 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 : reconstruire vignettes et carte depuis le cache à la demande // (/api/sync — sur la webapp légère, les tuiles proviennent de la machine de // traitement), puis recharger. /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(); }); // Arrêt de la génération en cours : SIGTERM au pipeline côté serveur, qui // nettoie ses workers et ses processus PDAL ; le sondage marque ensuite la // tâche « Interrompu » (returncode négatif). if (genStop) genStop.addEventListener('click', () => { genStop.disabled = true; fetch('/api/stop', { method: 'POST' }) .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) .catch(msg => { genStop.disabled = false; showGenBar('Erreur arrêt : ' + msg); }); }); 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' : s.returncode < 0 ? 'stopped' : '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); // Veille permanente : même sans file affichée dans cet onglet (génération // lancée depuis un autre onglet, un autre appareil, ou index reconstruit // côté serveur), la carte sonde /api/tiles toutes les 15 s — la réponse // allégée par ?stamp= la rend quasi gratuite quand rien n'a changé — et // fusionne les tuiles nouvelles ou régénérée dès que l'index bouge. Pendant // un run suivi par cet onglet, pollGenStatus() sonde déjà toutes les 2 s. setInterval(() => { if (!document.hidden && !genPollTimer) pollLiveTiles(); }, 15000); // ============================================================ // 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 """