"""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 { --bg: #0b0e13; --panel: rgba(16, 20, 27, 0.93); --panel-border: rgba(255, 255, 255, 0.09); --text: #d9dee6; --muted: #8b94a3; --accent: #5aa9e6; --accent-dim: #2e7cc4; --danger: #e94560; --success: #4cc98a; --success-dim: #2f8f63; --radius: 10px; --sbw: 320px; /* largeur de la barre latérale */ } * { box-sizing: border-box; margin: 0; padding: 0; } [hidden] { display: none !important; } html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); font-size: 13px; } /* Décalage des contrôles ancrés à gauche (échelle Leaflet, puce zoom) selon la présence de la barre latérale. */ body { --left-ui: calc(var(--sbw) + 24px); } body.sb-collapsed { --left-ui: 12px; } #viewport { position: absolute; inset: 0; background: var(--bg); } .leaflet-container { background: var(--bg); font: inherit; } /* Fond OSM assombri pour faire ressortir les tuiles LiDAR : simple opacité sur le tile-pane (un filter CSS forcerait sa rastérisation complète à chaque frame de pan/zoom — coûteux) */ #viewport .leaflet-tile-pane { opacity: 0.45; } #viewport.bright .leaflet-tile-pane { opacity: 1; } #viewport .leaflet-bottom.leaflet-left { left: var(--left-ui); } /* --- Panneaux : socle visuel commun (unité de l'interface) --- */ .panel { position: fixed; z-index: 900; background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); color: var(--text); } .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--panel-border); } .panel-title { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--panel-border); border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; transition: background 0.15s, border-color 0.15s; } button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); } button.primary { background: var(--accent-dim); border-color: var(--accent-dim); } button.primary:hover { background: var(--accent); } button:disabled { opacity: 0.4; cursor: not-allowed; } .icon-btn { padding: 2px 8px; font-size: 14px; line-height: 1.2; } select, input[type="checkbox"] { accent-color: var(--accent); } .gen-select { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--panel-border); border-radius: 6px; padding: 3px 6px; font-size: 12px; cursor: pointer; } /* --- Barre latérale (gauche, pleine hauteur) --- */ #sidebar { top: 12px; bottom: 12px; left: 12px; width: var(--sbw); display: flex; flex-direction: column; } .sidebar-head { padding: 10px 12px 8px; border-bottom: 1px solid var(--panel-border); } .head-row { display: flex; align-items: center; justify-content: space-between; } #sidebar h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; } #sidebar .stat { display: block; margin-top: 2px; font-size: 11px; color: var(--muted); } #sidebarCollapse { color: var(--muted); border: none; background: none; } #sidebarCollapse:hover { color: var(--text); } #sidebar.collapsed { bottom: auto; } #sidebar.collapsed .sidebar-body { display: none; } #sidebar.collapsed #sidebarCollapse { transform: rotate(180deg); } .sidebar-body { flex: 1; min-height: 0; display: flex; flex-direction: column; } /* Onglets : contrôle segmenté */ .tabbar { display: flex; gap: 4px; margin: 8px; padding: 3px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; } .tab { flex: 1; border: none; background: transparent; border-radius: 6px; padding: 6px 8px; font-size: 12px; font-weight: 600; color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; } .tab:hover { background: rgba(255, 255, 255, 0.07); color: var(--text); border-color: transparent; } .tab.active { background: var(--accent-dim); color: #fff; } .tab-badge { min-width: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: #06263f; font-size: 10px; font-weight: 700; line-height: 15px; } .tab-page { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 8px 10px; } /* --- Lignes de couches --- */ .layer-row { display: grid; grid-template-columns: 16px 24px minmax(0, 1fr) 80px 30px; align-items: center; gap: 6px; padding: 5px 6px; border-radius: 6px; user-select: none; } #osmRow { grid-template-columns: 24px minmax(0, 1fr) 80px 30px; } .layer-row:hover { background: rgba(255, 255, 255, 0.05); } .layer-row.dragging { opacity: 0.45; } .layer-grip { color: var(--muted); cursor: grab; font-size: 13px; text-align: center; line-height: 1; } .layer-grip:hover, .layer-row.dragging .layer-grip { color: var(--accent); } .eye { width: 24px; height: 24px; padding: 0; border: none; background: none; display: flex; align-items: center; justify-content: center; color: var(--muted); } .eye.on { color: var(--accent); } .eye svg { width: 16px; height: 16px; } .layer-name { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } .layer-row.on .layer-name { color: var(--text); } .layer-pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } .layer-sub { display: flex; align-items: center; justify-content: space-between; font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 6px 4px; border-top: 1px solid var(--panel-border); margin-top: 6px; } .layer-sub-actions { display: inline-flex; align-items: center; gap: 4px; } .layer-sub-actions .icon-btn { font-size: 10.5px; padding: 1px 6px; } /* Sliders d'opacité : piste fine dans une zone cliquable haute de 18 px — le pouce reste saisissable au drag (l'élément lui-même fait toute la hauteur, pas seulement la piste de 4 px). */ input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; background: transparent; outline: none; cursor: pointer; margin: 0; } input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.18); } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 13px; height: 13px; border-radius: 50%; background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; box-shadow: 0 0 0 1px rgba(90, 169, 230, 0.4); } input[type="range"]:hover::-webkit-slider-thumb { background: #7ec4f5; } input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.18); } input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; } /* --- Tuiles LiDAR sur la carte --- */ .ltile-anchor { width: 0; height: 0; } .ltile { position: absolute; left: 0; top: 0; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.10); /* Fond transparent obligatoire : l'opacité des couches doit laisser voir les couches du dessous et le fond de carte, pas un rectangle noir. */ background: transparent; /* Layout et peinture des tuiles hors écran skippés par le navigateur */ content-visibility: auto; } /* Uniquement pendant l'animation de zoom (classe posée par app.js) : scaling sur le GPU, puis libération pour ne pas immobiliser de mémoire. */ body.zooming .ltile { will-change: transform; } .ltile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; /* Évite le drag natif des images qui avale le mouseup (dessin de zones) */ pointer-events: none; -webkit-user-drag: none; user-select: none; } .ltile:hover { border-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } .ltile.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } /* --- Fiche tuile (onglet Tuile) --- */ .tile-info { padding: 6px 6px 10px; } .tile-info-head { display: flex; align-items: center; gap: 8px; } .tile-info-title { font-size: 13px; font-weight: 700; } .tile-info-badge { font-size: 10.5px; color: var(--accent); background: rgba(90, 169, 230, 0.12); border-radius: 999px; padding: 1px 8px; font-variant-numeric: tabular-nums; } .tile-info-close { margin-left: auto; color: var(--muted); border: none; background: none; font-size: 16px; } .tile-info-close:hover { color: var(--danger); } .tile-empty { padding: 20px 12px; color: var(--muted); font-size: 12px; line-height: 1.5; text-align: center; } .tile-info-dalle { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 10.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; } .tile-info-meta { font-size: 11.5px; color: var(--muted); margin-top: 4px; } .tile-info-sub { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 8px 0 6px; border-top: 1px solid var(--panel-border); padding-top: 8px; } /* Puces de couches : œil + nom + téléchargement, repliées sur 2 colonnes */ .vpills { display: flex; flex-wrap: wrap; gap: 5px; } .vpill { display: inline-flex; align-items: center; gap: 3px; padding: 2px 4px 2px 7px; border-radius: 999px; background: rgba(255, 255, 255, 0.05); } .vpill.on { background: rgba(90, 169, 230, 0.14); } .vpill-eye, .vpill-name { border: none; background: none; padding: 0; cursor: pointer; color: var(--muted); } .vpill-eye { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; } .vpill-eye svg { width: 13px; height: 13px; } .vpill.on .vpill-eye { color: var(--accent); } .vpill-name { font-size: 11.5px; color: var(--text); white-space: nowrap; } .vpill-name:hover { color: var(--accent); } .vpill-dl { color: var(--muted); text-decoration: none; font-size: 12px; padding: 0 4px; border-radius: 4px; } .vpill-dl:hover { color: var(--accent); } .tile-info-actions { margin-top: 10px; } .tile-info-actions button { width: 100%; font-size: 12px; padding: 6px 10px; } /* --- Outils carte (haut droite) --- */ #mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; } #mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; } #btnMenu { display: none; } #btnGps.busy { color: var(--accent); border-color: var(--accent); } /* --- Puces d'état (bas d'écran) --- */ .chip { position: fixed; z-index: 900; background: var(--panel); border: 1px solid var(--panel-border); border-radius: 6px; padding: 5px 10px; font-size: 11px; color: var(--muted); } #zoomIndicator { bottom: 12px; left: var(--left-ui); font-variant-numeric: tabular-nums; } #gpsToast { top: 54px; left: 50%; transform: translateX(-50%); max-width: 80vw; text-align: center; line-height: 1.5; } #hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; } /* --- Onglet Génération --- */ /* Actions Zone / Compléter : propres à l'onglet, pleine largeur */ .gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } .gen-actions button { width: 100%; font-size: 12.5px; } #btnGenZone, #btnGenSel { background: var(--accent-dim); border-color: var(--accent-dim); } #btnGenZone:hover, #btnGenSel:hover { background: var(--accent); border-color: var(--accent); } #btnComplete { font-weight: 600; letter-spacing: 0.02em; background: var(--success-dim); border-color: var(--success-dim); color: #eafff4; } #btnComplete:hover { background: var(--success); border-color: var(--success); } #btnComplete .ok-ic { margin-right: 6px; font-weight: 700; } #genBar { display: none; flex-direction: column; gap: 8px; margin-top: 8px; padding: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--panel-border); border-radius: 8px; } #genBar.show { display: flex; } #genMsg { font-size: 12.5px; line-height: 1.45; } .gen-opt { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--text); cursor: pointer; } .gen-opt.col { flex-direction: column; align-items: stretch; gap: 4px; } .gen-opt.col > span { color: var(--muted); font-size: 11px; } .gen-opt select { flex: 1; min-width: 0; } .gen-hint { color: var(--muted); font-size: 11px; padding: 0 2px; } .gen-run { display: flex; gap: 8px; margin-top: 2px; } .gen-run #genGo { flex: 1; } /* File de génération : section de l'onglet, largeur de la barre latérale */ #genQueue { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; } .queue-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 6px; } #genQueueCount { margin-left: auto; font-size: 11px; color: var(--muted); } #genQueueBody { display: flex; flex-direction: column; gap: 8px; } .gen-task { border: 1px solid var(--panel-border); border-radius: 6px; } .gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; flex-wrap: wrap; } .gen-task-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--muted); } .gen-task.running .gen-task-dot { background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite; } .gen-task.done .gen-task-dot { background: #39d98a; } .gen-task.failed .gen-task-dot, .gen-task.stopped .gen-task-dot { background: var(--danger); } @keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } .gen-task-label { font-size: 12px; flex: 1; min-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gen-task-state { font-size: 11px; white-space: nowrap; } .gen-task.running .gen-task-state { color: var(--accent); } .gen-task.done .gen-task-state { color: #39d98a; } .gen-task.failed .gen-task-state, .gen-task.stopped .gen-task-state { color: var(--danger); } .gen-task-time { font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; } .gen-task-toggle { padding: 1px 6px; font-size: 11px; } .gen-task-log { margin: 0 8px 8px; max-height: 140px; overflow: auto; background: #0d1017; border: 1px solid var(--panel-border); border-radius: 6px; padding: 8px 10px; font: 10.5px/1.45 ui-monospace, Menlo, monospace; color: #9fb3d9; white-space: pre-wrap; } /* Tuiles une par une : nom court + puces d'étapes (types de visualisation) */ .gen-task-tiles { margin: 0 8px 8px; display: flex; flex-direction: column; gap: 4px; } .gen-tile { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border: 1px solid var(--panel-border); border-radius: 6px; padding: 4px 8px; font-size: 11.5px; } .gen-tile-name { min-width: 78px; flex: none; font-variant-numeric: tabular-nums; color: var(--text); cursor: default; } .gen-tile-steps { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; } .gen-step { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: 999px; background: rgba(255, 255, 255, 0.05); color: var(--muted); font-size: 10.5px; white-space: nowrap; } .gen-step::before { content: '·'; font-weight: bold; } .gen-step.running { color: var(--accent); background: rgba(90, 169, 230, 0.12); } .gen-step.running::before { content: '⟳'; animation: gen-pulse 1.1s ease-in-out infinite; } .gen-step.ok { color: #39d98a; background: rgba(57, 217, 138, 0.10); } .gen-step.ok::before { content: '✓'; } .gen-step.fail { color: var(--danger); background: rgba(233, 69, 96, 0.12); } .gen-step.fail::before { content: '✗'; } .gen-step.skip::before { content: '–'; } .gen-tile-state { flex: none; font-size: 11px; } .gen-tile.done .gen-tile-state { color: #39d98a; } .gen-tile.failed .gen-tile-state { color: var(--danger); } /* --- Onglet Export : mosaïque de dalles adjacentes (téléphone) --- */ .export-intro { font-size: 12px; color: var(--muted); line-height: 1.5; padding: 4px 2px 0; } #btnExportSel { background: var(--accent-dim); border-color: var(--accent-dim); } #btnExportSel:hover { background: var(--accent); border-color: var(--accent); } .export-count { font-size: 12.5px; font-weight: 600; } .export-count .sub { font-weight: 400; color: var(--muted); font-size: 11px; } #exportBar { display: none; flex-direction: column; gap: 8px; margin-top: 8px; padding: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--panel-border); border-radius: 8px; } #exportBar.show { display: flex; } #exportMsg { font-size: 12.5px; line-height: 1.45; } #exportDone { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; } #exportDoneList { display: flex; flex-direction: column; gap: 6px; } #exportDoneList a { font-size: 11.5px; color: var(--accent); text-decoration: none; word-break: break-all; } #exportDoneList a:hover { text-decoration: underline; } #exportDoneList .dl-ic { margin-right: 4px; } /* Surlignage vert des dalles retenues pour l'export (surcouche écran uniquement — l'image exportée colle les dalles sans aucun trait). */ .ltile.exportSel { border-color: #39d98a; box-shadow: 0 0 0 1px #39d98a, 0 0 12px rgba(57, 217, 138, 0.5); } body.exporting, body.exporting #viewport { cursor: pointer; } /* Surlignage bleu des dalles retenues pour la régénération (mode « Sélectionner des tuiles » de l'onglet Génération). */ .ltile.genSel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } body.genSel, body.genSel #viewport { cursor: pointer; } /* Barres de défilement fines et discrètes */ .tab-page::-webkit-scrollbar, .gen-task-log::-webkit-scrollbar { width: 8px; } .tab-page::-webkit-scrollbar-thumb, .gen-task-log::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 4px; } .tab-page::-webkit-scrollbar-track, .gen-task-log::-webkit-scrollbar-track { background: transparent; } body.drawing, body.drawing #viewport { cursor: crosshair !important; } /* --- Responsive : la barre latérale devient un tiroir --- */ @media (max-width: 720px) { body { --left-ui: 12px; } #sidebar { width: min(320px, 88vw); z-index: 1200; transform: translateX(calc(-100% - 20px)); transition: transform 0.25s ease; } body.menu-open #sidebar { transform: none; } #btnMenu { display: inline-block; } #hint { display: none; } } """ _APP_JS = r"""'use strict'; /* Carte LiDAR — couches superposables, infos tuiles, génération de zones. */ // --- Données embarquées par index.py --- // TILES : tuiles affichables (corners WGS84, viz {thumb, full}, meta) // VIZ_META : {clé: {label}} des visualisations disponibles // STATS : {default_viz, n_tiles} const viewport = document.getElementById('viewport'); if (typeof L === 'undefined') { viewport.innerHTML = '
' + "Erreur : Leaflet n'a pas pu être chargé (accès Internet requis pour ouvrir cette page en ligne)." + '
'; } else { // ============================================================ // Carte Leaflet : zoom, pan, fond OSM // ============================================================ const map = L.map('viewport', { zoomControl: false, attributionControl: false, minZoom: 4, maxZoom: 19 }); // Pane dédié au dessin de zones : les tuiles LiDAR sont des marqueurs // (pane 600) et les vecteurs par défaut vivent en dessous (pane 400) ; // on place rectangle et cellules fantômes au-dessus (620), sans capter // les clics (pointer-events: none → la carte reste manipulable). map.createPane('genDraw'); map.getPane('genDraw').style.zIndex = 620; map.getPane('genDraw').style.pointerEvents = 'none'; window.map = map; // accès débogage / automatisations window.TILES = TILES; const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, keepBuffer: 1 }).addTo(map); L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map); // ============================================================ // État des couches (persisté en localStorage) // ============================================================ // order : pile de visualisations, index 0 = bas de pile. const LS_KEY = 'lidarLayers_v1'; function defaultLayers() { const order = Object.keys(VIZ_META); // Couches activées par défaut : seules celles-ci sont allumées, toutes les // autres sont désactivées (mais restent accessibles via le panneau). Repli // sur la couche « principale » (default_viz) si la liste n'est pas fournie // (index ancien) ; sinon la première couche disponible. const list = (Array.isArray(STATS.default_layers) && STATS.default_layers.length) ? STATS.default_layers : ((STATS.default_viz && VIZ_META[STATS.default_viz]) ? [STATS.default_viz] : [order[0]]); const onSet = new Set(list.filter(k => VIZ_META[k])); const on = {}, opacity = {}; for (const k of order) { on[k] = onSet.has(k); opacity[k] = 1; } return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } }; } function loadLayers() { const def = defaultLayers(); let st = def; try { const raw = localStorage.getItem(LS_KEY); if (raw) { const saved = JSON.parse(raw); const order = (Array.isArray(saved.order) ? saved.order : []) .filter(k => VIZ_META[k]); for (const k of Object.keys(VIZ_META)) { if (!order.includes(k)) order.push(k); } const on = {}, opacity = {}; for (const k of order) { const op = saved.opacity ? saved.opacity[k] : undefined; on[k] = saved.on && saved.on[k] === true; opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op))) ? 1 : Math.min(1, Math.max(0, Number(op))); } st = { order, on, opacity, osm: Object.assign({}, def.osm, saved.osm || {}) }; } } catch (e) { st = def; } // Lien partagé (vue + couches + transparences) : il prime sur l'état local. if (sharedHash) { if (sharedHash.layers) st = sharedHash.layers; if (sharedHash.osm) st = Object.assign({}, st, { osm: sharedHash.osm }); } return st; } // Lien partagé analysé une seule fois au chargement : // #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark (format détaillé // dans parseSharedHash). Les couches partagent l'ordre de pile (bas → haut) // et l'opacité en pourcentage, indépendamment du localStorage local. const sharedHash = parseSharedHash(); const state = { layers: loadLayers() }; function saveLayers() { try { localStorage.setItem(LS_KEY, JSON.stringify(state.layers)); } catch (e) { /* privé */ } } function activeLayers() { return state.layers.order.filter(k => state.layers.on[k] && VIZ_META[k]); } // ============================================================ // Marqueurs : une div par tuile, une par couche active. // Chaque entrée met en cache div + par couche + projections : // les parcours déclenchés par moveend/zoomend ne font ni querySelector // ni lecture de layout (getBoundingClientRect). // ============================================================ let gridBounds = null; const markers = new Map(); const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0); // Crée le marqueur Leaflet d'une tuile (utilisé à l'init et en direct, // quand une tuile terminée apparaît en cours de génération). function registerTile(t) { if (!t.corners) return; const center = [(t.corners[0][0] + t.corners[2][0]) / 2, (t.corners[0][1] + t.corners[2][1]) / 2]; const icon = L.divIcon({ className: 'ltile-anchor', html: '
', iconSize: [0, 0] }); const mk = L.marker(L.latLng(center[0], center[1]), { icon: icon, interactive: true, keyboard: false }).addTo(map); mk.on('click', () => { if (exportMode) { toggleExportSel(t); return; } if (genSelMode) { toggleGenSel(t); return; } if (!genMode) showTileInfo(t); }); const el = mk.getElement(); markers.set(tileKey(t), { mk: mk, t: t, div: el ? el.querySelector('.ltile') : null, imgs: {}, latlngs: t.corners.map(c => L.latLng(c[0], c[1])), bounds: L.latLngBounds(t.corners), proj: null, projZoom: NaN }); } function recomputeGridBounds() { gridBounds = null; for (const t of TILES) { if (!t.corners) continue; const b = L.latLngBounds(t.corners); gridBounds = gridBounds ? gridBounds.extend(b) : b; } } for (const t of TILES) registerTile(t); recomputeGridBounds(); function entryDiv(entry) { if (!entry.div) { const el = entry.mk.getElement(); entry.div = el ? el.querySelector('.ltile') : null; } return entry.div; } // Taille projetée (px) d'une tuile au zoom courant, mise en cache par zoom. function tileSize(entry) { const zoom = map.getZoom(); if (entry.projZoom === zoom && entry.proj) return entry.proj; const c = entry.latlngs; const sw = map.project(c[0], zoom), se = map.project(c[1], zoom), nw = map.project(c[3], zoom); entry.proj = { w: Math.hypot(se.x - sw.x, se.y - sw.y), h: Math.hypot(nw.x - sw.x, nw.y - sw.y) }; entry.projZoom = zoom; return entry.proj; } // Redimensionne + rote chaque tuile selon les coins projetés à l'échelle courante function layoutTiles() { // map.project() renvoie des pixels NON arrondis, contrairement à // latLngToContainerPoint() (arrondi entier) : au zoom global un bord de tuile // de 1 km ne fait que ~0,6 px et l'arrondi l'écrase à 0 (rotations fausses // de 0/45/90°, largeurs 0 ou 1 px, tuiles qui ne s'emboîtent plus). const zoom = map.getZoom(); for (const entry of markers.values()) { const div = entryDiv(entry); if (!div) continue; const c = entry.latlngs; const sw = map.project(c[0], zoom), se = map.project(c[1], zoom), ne = map.project(c[2], zoom), nw = map.project(c[3], zoom); const w = Math.hypot(se.x - sw.x, se.y - sw.y); const h = Math.hypot(nw.x - sw.x, nw.y - sw.y); const ang = Math.atan2(se.y - sw.y, se.x - sw.x) * 180 / Math.PI; // Compense l'arrondi entier de la position du marqueur (erreur <= 0,5 px) // pour que le bord partagé de 2 tuiles voisines coïncide exactement. const cx = (sw.x + ne.x) / 2, cy = (sw.y + ne.y) / 2; div.style.marginLeft = (cx - Math.round(cx)) + 'px'; div.style.marginTop = (cy - Math.round(cy)) + 'px'; div.style.width = w + 'px'; div.style.height = h + 'px'; div.style.transform = 'translate(-50%, -50%) rotate(' + ang.toFixed(4) + 'deg)'; entry.proj = { w: w, h: h }; entry.projZoom = zoom; } // Resynchronise aussi les : au premier rendu les marqueurs viennent // tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés. syncTileLayers(); } // Crée / supprime les de chaque tuile selon les couches actives, // applique opacité et ordre d'empilement (z-index). function syncTileLayers() { const act = activeLayers(); for (const entry of markers.values()) { const div = entryDiv(entry); if (!div) continue; for (const k of Object.keys(entry.imgs)) { if (act.includes(k)) continue; entry.imgs[k].src = ''; entry.imgs[k].remove(); delete entry.imgs[k]; } act.forEach((k, i) => { if (!entry.t.viz[k]) return; let img = entry.imgs[k]; if (!img) { img = document.createElement('img'); img.dataset.layer = k; img.alt = ''; img.decoding = 'async'; div.appendChild(img); entry.imgs[k] = img; } img.style.zIndex = String(i + 1); img.style.opacity = String(state.layers.opacity[k]); }); } updateImages(); } // Bascule vignette (256 px) ↔ vignette intermédiaire (640 px) ↔ image // pleine résolution selon le zoom : en vue d'ensemble la vignette suffit ; // au-delà de ~900 px à l'écran on charge l'image complète pour conserver // les détails, l'intermédiaire faisant la transition. En dézoomant on // retombe sur la vignette pour libérer la mémoire. Visibilité et tailles // sont calculées en coordonnées projetées : aucun reflow. const PRELOAD_MARGIN_PX = 300; const FULL_RES_PX = 900; function updateImages() { if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire const zoom = map.getZoom(); const b = map.getBounds(); const swP = map.project(b.getSouthWest(), zoom); const neP = map.project(b.getNorthEast(), zoom); const visBounds = L.latLngBounds( map.unproject(L.point(swP.x - PRELOAD_MARGIN_PX, swP.y + PRELOAD_MARGIN_PX), zoom), map.unproject(L.point(neP.x + PRELOAD_MARGIN_PX, neP.y - PRELOAD_MARGIN_PX), zoom)); for (const entry of markers.values()) { const visible = visBounds.intersects(entry.bounds); const p = tileSize(entry); const big = visible && (p.w > 300 || p.h > 300); for (const k in entry.imgs) { const v = entry.t.viz[k]; if (!v) continue; // Les couches quasi transparentes n'ont pas besoin de pleine résolution let want = v.thumb; if (big && state.layers.opacity[k] > 0.15) { want = (v.mid && Math.max(p.w, p.h) < FULL_RES_PX) ? v.mid : v.full; } const img = entry.imgs[k]; if (!img.src.endsWith(want)) img.src = want; } } } // ============================================================ // Panneau de couches // ============================================================ const SVG_EYE = ''; const SVG_EYE_OFF = ''; const vizLayersEl = document.getElementById('vizLayers'); let dragKey = null; function renderOsmRow() { const { on, opacity, dark } = state.layers.osm; const toggle = document.getElementById('osmToggle'); const slider = document.getElementById('osmOpacity'); const pct = document.getElementById('osmPct'); toggle.classList.toggle('on', on); toggle.innerHTML = on ? SVG_EYE : SVG_EYE_OFF; slider.value = Math.round(opacity * 100); pct.textContent = Math.round(opacity * 100) + '%'; if (on) { if (!map.hasLayer(osmLayer)) osmLayer.addTo(map); } else { if (map.hasLayer(osmLayer)) map.removeLayer(osmLayer); } osmLayer.setOpacity(opacity); viewport.classList.toggle('bright', !dark); } function renderLayerPanel() { vizLayersEl.innerHTML = ''; // Affichage haut de pile en premier (le tableau order est bas → haut). const topFirst = state.layers.order.slice().reverse(); for (const key of topFirst) { const meta = VIZ_META[key]; if (!meta) continue; const row = document.createElement('div'); row.className = 'layer-row' + (state.layers.on[key] ? ' on' : ''); // Le glisser-déposer ne part que de la poignée ⠿ : sinon le drag natif // de la ligne avale le mousedown des sliders (opacité non manipulable // à la souris) et les clics sur les boutons. row.draggable = false; row.dataset.layer = key; row.addEventListener('mousedown', e => { row.draggable = !!(e.target.closest && e.target.closest('.layer-grip')); }); row.title = meta.label; row.innerHTML = '⠿' + '' + '' + meta.label + '' + '' + '' + 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'; // Barre latérale : repli + onglets (Couches / Génération) const sidebar = document.getElementById('sidebar'); function setSidebarCollapsed(collapsed) { sidebar.classList.toggle('collapsed', collapsed); document.body.classList.toggle('sb-collapsed', collapsed); try { localStorage.setItem('lidarSidebarCollapsed', collapsed ? '1' : ''); } catch (e) {} } document.getElementById('sidebarCollapse').addEventListener('click', () => setSidebarCollapsed(!sidebar.classList.contains('collapsed'))); try { if (localStorage.getItem('lidarSidebarCollapsed') === '1') setSidebarCollapsed(true); } catch (e) {} const tabButtons = document.querySelectorAll('.tabbar .tab'); let activeTab = 'layers'; function switchTab(name) { activeTab = name; for (const b of tabButtons) { b.classList.toggle('active', b.dataset.tab === name); } document.querySelectorAll('.tab-page').forEach(p => { p.hidden = p.id !== 'tab-' + name; }); // L'onglet Tuile est contextuel : sa sélection n'est pas mémorisée if (name === 'layers' || name === 'gen') { try { localStorage.setItem('lidarTab', name); } catch (e) {} } } for (const b of tabButtons) b.addEventListener('click', () => switchTab(b.dataset.tab)); try { const savedTab = localStorage.getItem('lidarTab'); if (savedTab === 'layers' || savedTab === 'gen') switchTab(savedTab); } catch (e) {} document.getElementById('btnMenu').addEventListener('click', () => document.body.classList.toggle('menu-open')); // ============================================================ // Vue : zoom / pan / persistance 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 """