"""Carte continue interactive des tuiles LiDAR traitées. Génère l'interface web de consultation (servie par webapp.py via --serve) : - output/index.html : coquille HTML minimaliste embarquant les données - output/assets/app.css : styles (panneaux flottants type SIG) - output/assets/app.js : logique carte (Leaflet), couches, infos tuiles Fonctionnalités : - Carte continue zoom/pan (Leaflet), tuiles rotées selon la projection Lambert 93, vignettes ↔ images pleine résolution selon le zoom. - Panneau de couches superposables avec opacité individuelle et réordonnancement par glisser-déposer (persisté en localStorage). - Panneau d'infos par tuile : résolution, méthode de classification du sol (lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers. - Génération de nouvelles zones via l'API web (./run.sh --serve) : dessin d'un rectangle → téléchargement IGN + traitement. Intégration: - Appelé automatiquement à la fin de process_all() dans pipeline.py - Régénération solo via --rebuild-index dans cli.py """ import json import logging import re import time from datetime import datetime from pathlib import Path logger = logging.getLogger("lidar") # Noms d'affichage (français) pour le panneau de couches. # Clé = mot-clé dans le nom de fichier de sortie (post-basename). VIZ_LABELS = { 'hillshade_multi': 'Hillshade multidirectionnel', 'slope': 'Pente', 'aspect': 'Aspect', 'mslrm': 'MSRM (relief multi-échelle)', 'sailore': 'SAILORE (LRM adaptatif)', 'positive_openness': 'Openness positive', 'negative_openness': 'Openness négative', 'svf': 'Sky-View Factor', 'aniso_open': 'Openness anisotropique', 'roughness': 'Rugosité', 'wavelet': 'Ondelette', 'flow_acc': 'Accumulation d\'écoulement', 'solar': 'Éclairage solaire', 'anomaly': 'Carte d\'anomalies', 'ortho': 'Orthophoto IGN', 'topo': 'Carte topographique IGN', } # Couche activée par défaut à l'ouverture de la carte. DEFAULT_VIZ = 'hillshade_multi' # Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la # carte — les autres viz restent disponibles en repli dalle entière. # Liste vide = découper toutes les visualisations disponibles. _CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi') # Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles : # q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur). # speed 0 (lent/meilleur) → 10 (rapide) ; 6+8 quasi identique en taille, # on garde 8 (2× plus rapide) pour 2500×2500 px. _SUBTILE_AVIF_QUALITY = 55 _SUBTILE_AVIF_SPEED = 8 # Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli. _VIZ_FALLBACK_ORDER = [ 'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness', 'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness', 'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo', ] # Regex pour parser les coordonnées tuile dans le basename LHD. # LHD_FXX_{COL}_{ROW}_PTS_LAMB93_IGN69 (COL/ROW en km, Lambert 93) _RE_LHD_COORDS = re.compile(r'^LHD_FXX_(\d+)_(\d+)_PTS_LAMB93') def parse_basename_coords(name): """Extrait les coordonnées tuile (col, row en km) depuis un basename. Args: name: basename potentiel (ex: 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69') ou nom de dossier avec suffixe résolution ('..._r0p2'). Returns: (col_km, row_km) ou None si le nom ne correspond pas au pattern LHD. """ m = _RE_LHD_COORDS.match(name) if not m: return None return int(m.group(1)), int(m.group(2)) def _strip_res_suffix(dirname): """Sépare le basename de base et la résolution d'un nom de dossier de viz. 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69' → (basename, 0.5) 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2' → (basename, 0.2) Returns: (basename_without_suffix, resolution_float) ou (dirname, 0.5) si pas de suffixe. """ m = re.match(r'^(.+?)_r(\d+p\d+)$', dirname) if m: res_str = m.group(2).replace('p', '.') try: return m.group(1), float(res_str) except ValueError: pass return dirname, 0.5 def _res_suffix_str(resolution): """Suffixe de nommage d'une résolution (miroir de pipeline._res_suffix).""" if resolution == 0.5: return '' return '_r' + f'{resolution}'.replace('.', 'p') def scan_tiles(vis_dir): """Scanne le dossier des visualisations pour inventorier les tuiles traitées. Args: vis_dir: Path vers output/visualisations/ Returns: Liste de dictionnaires: {basename, col, row, resolution, dir_path, viz: {viz_key: {filename, ext}}, dir_name} Triée par (resolution, row, col). """ vis_dir = Path(vis_dir) if not vis_dir.is_dir(): return [] tiles = [] for entry in sorted(vis_dir.iterdir()): if not entry.is_dir(): continue coords = parse_basename_coords(entry.name) if coords is None: continue col, row = coords basename, resolution = _strip_res_suffix(entry.name) # Liste les fichiers image de visualisation dans le dossier. viz = {} for f in sorted(entry.iterdir()): if not f.is_file(): continue # Détection extension AVIF/WebP ext = None low = f.name.lower() for e in ('.avif', '.webp'): if low.endswith(e): ext = e.lstrip('.') break if ext is None: continue # viz_key = nom sans le préfixe basename_ ni l'extension stem = f.name[:-len('.' + ext)] prefix = basename + '_' if not stem.startswith(prefix): continue viz_key = stem[len(prefix):] viz[viz_key] = {'filename': f.name, 'ext': ext} if not viz: # Dossier vide ou sans image valide → ignoré continue tiles.append({ 'basename': basename, 'col': col, 'row': row, 'resolution': resolution, 'dir_name': entry.name, 'dir_path': str(entry), 'viz': viz, }) tiles.sort(key=lambda t: (t['resolution'], -t['row'], t['col'])) return tiles def compute_bbox(tiles): """Calcule la bounding box (en km) couverte par les tuiles. Returns: Dict {min_col, max_col, min_row, max_row} ou None si aucune tuile. """ if not tiles: return None cols = [t['col'] for t in tiles] rows = [t['row'] for t in tiles] return { 'min_col': min(cols), 'max_col': max(cols), 'min_row': min(rows), 'max_row': max(rows), } def compute_zones(tiles, proximity_threshold=15): """Regroupe les tuiles en zones géographiques par clustering de proximité. Les tuiles à moins de proximity_threshold km les unes des autres sont regroupées dans la même zone. Les zones sont triées par taille décroissante (plus grande zone en premier). Args: tiles: Liste de dictionnaires de tuiles. proximity_threshold: Distance maximale en km pour regrouper deux tuiles. Returns: Liste de dictionnaires de zones: {label, tiles, bbox} """ if not tiles: return [] # Union-Find pour le clustering parent = list(range(len(tiles))) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(x, y): px, py = find(x), find(y) if px != py: parent[px] = py # Regrouper les tuiles proches for i in range(len(tiles)): for j in range(i + 1, len(tiles)): dc = abs(tiles[i]['col'] - tiles[j]['col']) dr = abs(tiles[i]['row'] - tiles[j]['row']) if max(dc, dr) <= proximity_threshold: union(i, j) # Construire les zones zone_members = {} for i in range(len(tiles)): root = find(i) if root not in zone_members: zone_members[root] = [] zone_members[root].append(tiles[i]) zones = [] for i, zone_tiles in enumerate(zone_members.values(), 1): zone_bbox = compute_bbox(zone_tiles) zones.append({ 'label': f'Zone {i} ({len(zone_tiles)} tuiles)', 'tiles': zone_tiles, 'bbox': zone_bbox, }) # Trier par taille décroissante zones.sort(key=lambda z: len(z['tiles']), reverse=True) return zones def _approx_l93_to_wgs84(x_m, y_m): """Approximation affine Lambert 93 → WGS84 (fallback sans rasterio). Origine exacte: (700000, 6600000) L93 ↔ (3.0°E, 46.5°N). Précision de l'ordre du km — utilisée seulement si rasterio/PROJ est indisponible (jamais le cas dans le Docker). """ import math lat = 46.5 + (y_m - 6600000.0) / 111320.0 lon = 3.0 + (x_m - 700000.0) / (111320.0 * math.cos(math.radians(47.0))) return lon, lat def attach_gps_bounds(tiles): """Attache à chaque tuile ses coins GPS pour l'affichage Leaflet. Chaque tuile 1×1 km est définie par son coin nord-ouest en km L93 (col, row) → X ∈ [col, col+1] km, Y ∈ [row-1, row] km. (Vérifié sur les bounds des DTM : X_min = col×1000, Y_max = row×1000.) Utilise rasterio.warp (conversion PROJ exacte) si disponible, sinon l'approximation affine _approx_l93_to_wgs84. Ajoute à chaque tuile: corners: [[lat, lon] × 4] dans l'ordre SW, SE, NE, NW bounds : [[lat_sud, lon_ouest], [lat_nord, lon_est]] """ try: from rasterio.warp import transform as warp_transform xs = [] ys = [] for t in tiles: # SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000 xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000, (t['col'] + 1) * 1000, t['col'] * 1000]) ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000, t['row'] * 1000, t['row'] * 1000]) lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys) ok = True except Exception as e: logger.debug(f"Coins GPS approximatifs (rasterio indisponible: {e})") lons = None lats = None ok = False for i, t in enumerate(tiles): if ok: corners = [[lats[4 * i], lons[4 * i]], [lats[4 * i + 1], lons[4 * i + 1]], [lats[4 * i + 2], lons[4 * i + 2]], [lats[4 * i + 3], lons[4 * i + 3]]] else: corners = [] for cx in (t['col'] * 1000, (t['col'] + 1) * 1000): for cy in ((t['row'] - 1) * 1000, t['row'] * 1000): lon, lat = _approx_l93_to_wgs84(cx, cy) corners.append([lat, lon]) # Reordonner SW, SE, NE, NW (la boucle donne SW, NW, SE, NE) corners = [corners[0], corners[2], corners[3], corners[1]] t['corners'] = corners t['bounds'] = [[min(c[0] for c in corners), min(c[1] for c in corners)], [max(c[0] for c in corners), max(c[1] for c in corners)]] return ok def _mtime(path): """Mtime d'un fichier, ou None si inaccessible.""" try: return Path(path).stat().st_mtime except OSError: return None def _cached_file_fresh(path, src_mtime): """True si un fichier cache existe et est plus récent que sa source. Sert à invalider vignettes et sous-tuiles quand une tuile est recalculée : l'image source (AVIF/WebP) étant réécrite, sa mtime devient plus récente que celle du cache, qui doit alors être régénéré. """ cached_mtime = _mtime(path) if cached_mtime is None: return False return src_mtime is None or cached_mtime >= src_mtime def generate_thumbnail(src_path, thumb_path, max_size=256): """Génère une vignette JPEG depuis une image AVIF/WebP existante. Args: src_path: chemin de l'image source (AVIF/WebP). thumb_path: chemin de sortie JPEG. max_size: taille maximale (côté le plus grand) en pixels. Returns: True si OK, False si échec. """ try: from PIL import Image as PILImage except ImportError: logger.warning("PIL indisponible — impossible de générer les vignettes") return False try: img = PILImage.open(str(src_path)) img = img.convert('RGB') w, h = img.size scale = min(1.0, max_size / max(w, h)) if scale < 1.0: new_size = (max(1, int(w * scale)), max(1, int(h * scale))) try: resample = PILImage.Resampling.LANCZOS except AttributeError: resample = getattr(PILImage, 'LANCZOS', 1) img = img.resize(new_size, resample) Path(thumb_path).parent.mkdir(parents=True, exist_ok=True) img.save(str(thumb_path), format='JPEG', quality=80) return True except Exception as e: logger.debug(f"Vignette ignorée {src_path}: {e}") return False def _pick_display_viz(viz_keys): """Choisit la visualisation par défaut pour une tuile. Privilégie hillshade_multi, sinon la première disponible selon l'ordre de repli. """ for v in _VIZ_FALLBACK_ORDER: if v in viz_keys: return v return sorted(viz_keys)[0] def _subdivision_k(resolution, tile_m=1000, target_px=2500): """Facteur de découpage k (grille k×k) pour alléger le rendu carte. Au 0,2 m/px une dalle de 1 km fait 5000×5000 px (~100 Mo décodés dans le navigateur) : on la découpe en quadrants de 500 m (k=2, 2500×2500 px). À 0,5 m/px (2000 px) la dalle reste entière (k=1). """ px = max(1, int(round(tile_m / resolution))) return max(1, int(round(px / target_px))) def _subtile_corners(corners, i, j, k): """Coins WGS84 [SW, SE, NE, NW] de la sous-tuile (i, j) d'un découpage k×k. i : indice vers l'est (0..k-1), j : indice vers le nord (0..k-1). Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté est quasi un parallélogramme à cette échelle (erreur écran < 1 px). """ sw, se, ne, nw = corners def lerp(p, q, u): return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u] def at(u, v): return lerp(lerp(sw, se, u), lerp(nw, ne, u), v) u0, u1 = i / k, (i + 1) / k v0, v1 = j / k, (j + 1) / k return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)] def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): """Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive. Ne découpe que les visualisations proposées dans le panneau de couches. Retourne la liste des entrées display (une par sous-tuile), ou None si le découpage n'est pas nécessaire/possible (la dalle entière sera alors affichée). """ k = _subdivision_k(tile['resolution']) if k <= 1: return None try: from PIL import Image as PILImage except ImportError: return None sub_dir = output_dir / sub_dir_name sub_dir.mkdir(parents=True, exist_ok=True) entries = {} for j in range(k): for i in range(k): corners = _subtile_corners(tile['corners'], i, j, k) entries[(i, j)] = { 'col': tile['col'], 'row': tile['row'], 'name': tile['name'], 'dir_name': tile['dir_name'], 'resolution': tile['resolution'], 'bounds': [[min(c[0] for c in corners), min(c[1] for c in corners)], [max(c[0] for c in corners), max(c[1] for c in corners)]], 'corners': corners, 'display_viz': tile['display_viz'], 'viz': {}, 'meta': tile.get('meta'), 'sub_i': i, 'sub_j': j, 'sub_k': k, 'size_km': round(1.0 / k, 3), } try: resample = getattr(PILImage, 'LANCZOS', 1) for viz_key in offered_viz_keys: info = tile['viz'].get(viz_key) if not info: continue stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}" for key in entries} # Régénère si au moins un fichier manque ou est périmé (dalle # source recalculée depuis — comme les vignettes) src = output_dir / info['full'] src_mtime = _mtime(src) all_exist = all( _cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime) and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime) for stem in stems.values()) if not all_exist: logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} " f"({len(stems)} découpages)") try: img = PILImage.open(str(src)) img.load() except Exception as e: logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}") return None if img.mode not in ('RGB', 'L'): img = img.convert('RGB') W, H = img.size for (i, j), stem in stems.items(): # Image : ligne 0 = nord → la sous-tuile j (nord) est en haut left, right = round(W * i / k), round(W * (i + 1) / k) top = round(H * (1 - (j + 1) / k)) bottom = round(H * (1 - j / k)) quad = img.crop((left, top, right, bottom)) try: quad.save(str(output_dir / sub_dir_name / (stem + '.avif')), format='AVIF', quality=_SUBTILE_AVIF_QUALITY, speed=_SUBTILE_AVIF_SPEED) except Exception as e: logger.warning(f"Encodage AVIF impossible ({stem}), " f"sous-tuilage abandonné : {e}") return None # Supprime l'ancien crop .webp d'une génération précédente (output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True) scale = min(1.0, 256 / max(quad.size)) if scale < 1.0: quad = quad.resize((max(1, int(quad.size[0] * scale)), max(1, int(quad.size[1] * scale))), resample) quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')), format='WEBP', quality=80) for (i, j), stem in stems.items(): entries[(i, j)]['viz'][viz_key] = { 'thumb': f"{sub_dir_name}/{stem}_thumb.webp", 'full': f"{sub_dir_name}/{stem}.avif", } except Exception as e: logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}") return None usable = [e for e in entries.values() if e['viz']] if not usable: return None # Repli dalle entière pour les visualisations non découpées : elles restent # superposables en couche (images plus lourdes, mais fonctionnelles). sub_keys = set(offered_viz_keys) for entry in usable: for viz_key, info in tile['viz'].items(): if viz_key not in sub_keys: entry['viz'][viz_key] = dict(info) return usable def _collect_tile_metadata(tile, dtm_dir): """Rassemble les métadonnées de génération d'une tuile. Lit la méthode de classification du sol depuis le sidecar DTM (output/DTM/{basename}_dtm{suffix}_method.txt, écrit par pipeline.py), et les dates/tailles des fichiers de visualisation. Returns: {method: str|None, generated: str|None, viz: {viz_key: {date: str, size: int}}} """ meta = {'method': None, 'generated': None, 'viz': {}} suffix = _res_suffix_str(tile['resolution']) method_file = Path(dtm_dir) / f"{tile['basename']}_dtm{suffix}_method.txt" if not method_file.exists() and suffix: # La classification du sol est partagée entre résolutions : repli sur # le sidecar de la résolution primaire si le spécifique manque. method_file = Path(dtm_dir) / f"{tile['basename']}_dtm_method.txt" try: if method_file.exists(): method = method_file.read_text(encoding='utf-8').strip() if method: meta['method'] = method # Le sidecar est écrit juste après la création du DTM : # sa date ≈ date de génération de la dalle. meta['generated'] = datetime.fromtimestamp( method_file.stat().st_mtime).strftime('%Y-%m-%d %H:%M') except OSError as e: logger.debug(f"Métadonnées illisibles {method_file.name}: {e}") for viz_key, info in tile['viz'].items(): try: st = (Path(tile['dir_path']) / info['filename']).stat() meta['viz'][viz_key] = { 'date': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'), 'size': st.st_size, } except OSError: continue if meta['generated'] is None and meta['viz']: dates = [v['date'] for v in meta['viz'].values()] meta['generated'] = min(dates) return meta def build_index(output_dir, output_format='avif'): """Génère la carte interactive HTML des tuiles traitées, organisée par zones. Scanne output_dir/visualisations/, collecte les métadonnées de génération, génère les vignettes JPEG, écrit les assets (CSS/JS) puis output_dir/index.html. Args: output_dir: dossier de sortie racine (contient visualisations/). output_format: format des images ('avif' ou 'webp') — pour info. Returns: Path vers index.html si succès, None si échec ou aucune tuile. """ output_dir = Path(output_dir) vis_dir = output_dir / 'visualisations' dtm_dir = output_dir / 'DTM' t_start = time.time() tiles = scan_tiles(vis_dir) if not tiles: logger.info("Aucune tuile traitée trouvée — index global non généré") return None # Bounds GPS par tuile (géoréférencement exact pour la carte Leaflet) attach_gps_bounds(tiles) # Une seule tuile par position (col, row) : on garde la résolution la plus # fine disponible. Sinon les versions 0,5 m et 0,2 m d'une même dalle se # superposent exactement sur la carte et celle ajoutée en dernier dans le # DOM (la moins résolue, tri croissant) masque l'autre. best_by_pos = {} for t in tiles: key = (t['col'], t['row']) if key not in best_by_pos or t['resolution'] < best_by_pos[key]['resolution']: best_by_pos[key] = t tiles = sorted(best_by_pos.values(), key=lambda t: (t['resolution'], -t['row'], t['col'])) # Détecter les zones géographiques (pour info uniquement) zones = compute_zones(tiles) logger.info(f" {len(zones)} zone(s) détectée(s)") thumb_dir = output_dir / 'index_thumbs' thumb_dir.mkdir(parents=True, exist_ok=True) # Collecte toutes les visualisations disponibles (pour le panneau de couches). all_viz_keys = set() for t in tiles: all_viz_keys.update(t['viz'].keys()) # Restreint le découpage en sous-tuiles aux visualisations choisies if _CARTO_SUBTILED_VIZ: sub_viz = [v for v in _CARTO_SUBTILED_VIZ if v in all_viz_keys] if sub_viz: logger.info(f" Sous-tuilage limité à : {', '.join(sub_viz)}") else: sub_viz = list(all_viz_keys) # Génère les vignettes et construit les données pour le HTML. zone_records = [] thumbs_generated = 0 thumbs_failed = 0 tile_idx = 0 n_tiles = len(tiles) logger.info(f" Vignettes : {n_tiles} tuile(s) × {len(all_viz_keys)} visualisation(s)") for zone in zones: zone_tile_records = [] for t in zone['tiles']: tile_idx += 1 viz_thumbs = {} regen = 0 for viz_key, info in t['viz'].items(): src = Path(t['dir_path']) / info['filename'] thumb_name = f"{t['dir_name']}_{viz_key}.jpg" thumb_path = thumb_dir / thumb_name # Régénère si manquante ou périmée (tuile recalculée depuis) if not _cached_file_fresh(thumb_path, _mtime(src)): if generate_thumbnail(src, thumb_path): thumbs_generated += 1 regen += 1 else: thumbs_failed += 1 continue else: thumbs_generated += 1 viz_thumbs[viz_key] = { 'thumb': f"index_thumbs/{thumb_name}", 'full': f"visualisations/{t['dir_name']}/{info['filename']}", } if regen: logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — " f"{regen} vignette(s) régénérée(s)") if not viz_thumbs: continue display_viz = _pick_display_viz(viz_thumbs.keys()) tile_meta = _collect_tile_metadata(t, dtm_dir) zone_tile_records.append({ 'col': t['col'], 'row': t['row'], 'name': t['basename'], 'dir_name': t['dir_name'], 'resolution': t['resolution'], 'bounds': t.get('bounds'), 'corners': t.get('corners'), 'display_viz': display_viz, 'viz': viz_thumbs, 'meta': tile_meta, }) if zone_tile_records: zone_records.append({ 'label': zone['label'], 'tiles': zone_tile_records, 'bbox': zone['bbox'], }) if not zone_records: logger.warning("Aucune vignette générée — index global abandonné") return None # Calculer le bbox global (pour la stat bar) global_bbox = compute_bbox(tiles) # HTML avec données intégrées : liste plate des quads affichables. # Les dalles 0,2 m (5000×5000 px) sont découpées en sous-tuiles 500 m # (quadrants 2500×2500 px) pour alléger mémoire et chargements navigateur. sub_dir_name = 'index_subtiles' display_tiles = [] n_dalles = 0 n_sous = 0 for zr in zone_records: for t in zr['tiles']: n_dalles += 1 subs = _build_subtiles(t, sub_viz, output_dir, sub_dir_name) if subs: display_tiles.extend(subs) n_sous += len(subs) else: display_tiles.append(t) if n_sous: logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)") _write_assets(output_dir) html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format) html_path = output_dir / 'index.html' html_path.write_text(html, encoding='utf-8') logger.info(f"Index global généré : {html_path} ({time.time() - t_start:.1f}s)") logger.info(f" {len(tiles)} tuile(s) • {thumbs_generated} vignette(s) générée(s)" + (f" • {thumbs_failed} échec(s)" if thumbs_failed else "")) logger.info(f" Grille : {global_bbox['min_col']}-{global_bbox['max_col']} km E × " f"{global_bbox['min_row']}-{global_bbox['max_row']} km N") return html_path def _write_assets(output_dir): """Écrit les fichiers statiques de l'interface (CSS + JS) dans output/assets/.""" assets_dir = Path(output_dir) / 'assets' assets_dir.mkdir(parents=True, exist_ok=True) (assets_dir / 'app.css').write_text(_APP_CSS, encoding='utf-8') (assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8') def _render_html(tiles, global_bbox, all_viz_keys, output_format): """Construit la coquille HTML (données intégrées, styles/JS dans assets/).""" # 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 all_viz_keys] for v in sorted(all_viz_keys): if v not in ordered_viz: ordered_viz.append(v) tiles_json = json.dumps(tiles, ensure_ascii=False) viz_meta_json = json.dumps({ k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz }, ensure_ascii=False) # Couche activée par défaut : DEFAULT_VIZ si proposée, sinon la 1re default_viz = DEFAULT_VIZ if DEFAULT_VIZ in ordered_viz else (ordered_viz[0] if ordered_viz else DEFAULT_VIZ) stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)}, ensure_ascii=False) n_tiles = len(tiles) grid_w = global_bbox['max_col'] - global_bbox['min_col'] + 1 grid_h = global_bbox['max_row'] - global_bbox['min_row'] + 1 return _HTML_TEMPLATE.format( tiles_json=tiles_json, viz_meta_json=viz_meta_json, stats_json=stats_json, n_tiles=n_tiles, grid_w=grid_w, grid_h=grid_h, output_format=output_format.upper(), ) _HTML_TEMPLATE = """ Carte LiDAR

Carte LiDAR

{n_tiles} tuile(s) · {grid_w}×{grid_h} km · {output_format}
Zoom
Molette : zoom · Clic-glisser : déplacer
Clic sur tuile : infos de génération
""" _APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */ :root { --bg: #0b0e13; --panel: rgba(16, 20, 27, 0.9); --panel-border: rgba(255, 255, 255, 0.09); --text: #d9dee6; --muted: #8b94a3; --accent: #5aa9e6; --accent-dim: #2e7cc4; --danger: #e94560; --radius: 10px; } * { box-sizing: border-box; margin: 0; padding: 0; } html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); font-size: 13px; } #viewport { position: absolute; inset: 0; background: var(--bg); } .leaflet-container { background: var(--bg); font: inherit; } /* Fond OSM assombri pour faire ressortir les tuiles LiDAR */ #viewport .leaflet-tile-pane { filter: grayscale(0.6) brightness(0.5) contrast(1.2); } #viewport.bright .leaflet-tile-pane { filter: none; } /* --- Panneaux flottants --- */ .panel { position: fixed; z-index: 900; background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); color: var(--text); } .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--panel-border); } .panel-title { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--panel-border); border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; transition: background 0.15s, border-color 0.15s; } button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); } button.primary { background: var(--accent-dim); border-color: var(--accent-dim); } button.primary:hover { background: var(--accent); } button:disabled { opacity: 0.4; cursor: not-allowed; } .icon-btn { padding: 2px 8px; font-size: 14px; line-height: 1.2; } select, input[type="checkbox"] { accent-color: var(--accent); } .gen-select { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--panel-border); border-radius: 4px; padding: 3px 6px; font-size: 12px; cursor: pointer; } /* --- En-tête (marque + stats) --- */ #brand { top: 12px; left: 12px; padding: 8px 14px; } #brand h1 { font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 0.02em; } #brand .stat { font-size: 11.5px; color: var(--muted); } /* --- Outils carte (haut droite) --- */ #mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; } #mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; } #mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); } #mapTools #btnGenZone:hover { background: var(--accent); } /* --- Panneau de couches (gauche) --- */ #layerPanel { top: 74px; left: 12px; width: 300px; } #layerPanel.collapsed #layerBody { display: none; } #layerPanel.collapsed #layerCollapse { transform: rotate(-90deg); } #layerCollapse { border: none; background: none; color: var(--muted); } #layerBody { padding: 6px; max-height: calc(100vh - 200px); overflow-y: auto; } .layer-row { display: grid; grid-template-columns: 26px 1fr 74px 36px; align-items: center; gap: 6px; padding: 5px 6px; border-radius: 6px; } .layer-row:hover { background: rgba(255, 255, 255, 0.05); } .layer-row.dragging { opacity: 0.45; } .layer-row[data-layer] { cursor: grab; } .eye { width: 24px; height: 24px; padding: 0; border: none; background: none; display: flex; align-items: center; justify-content: center; color: var(--muted); } .eye.on { color: var(--accent); } .eye svg { width: 16px; height: 16px; } .layer-name { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } .layer-row.on .layer-name { color: var(--text); } .layer-pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } .layer-sub { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 6px 4px; border-top: 1px solid var(--panel-border); margin-top: 4px; } input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 3px; border-radius: 2px; background: rgba(255, 255, 255, 0.15); outline: none; cursor: pointer; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 11px; height: 11px; border-radius: 50%; background: var(--accent); border: none; } input[type="range"]::-moz-range-thumb { width: 11px; height: 11px; border-radius: 50%; background: var(--accent); border: none; } /* --- Tuiles LiDAR sur la carte --- */ .ltile-anchor { width: 0; height: 0; } .ltile { position: absolute; left: 0; top: 0; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.10); /* Fond transparent obligatoire : l'opacité des couches doit laisser voir les couches du dessous et le fond de carte, pas un rectangle noir. */ background: transparent; } .ltile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; /* Évite le drag natif des images qui avale le mouseup (dessin de zones) */ pointer-events: none; -webkit-user-drag: none; user-select: none; } .ltile:hover { border-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } .ltile.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } /* --- Panneau d'infos tuile (droite) --- */ #infoPanel { top: 60px; right: 12px; width: 330px; } #infoBody { padding: 10px 12px; max-height: calc(100vh - 180px); overflow-y: auto; } #infoClose { border: none; background: none; color: var(--muted); font-size: 18px; } #infoClose:hover { color: var(--danger); } .meta-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin-bottom: 10px; } .meta-list dt { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px; } .meta-list dd { font-size: 12.5px; } .meta-list dd.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; } .info-sub { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 10px 0 4px; border-top: 1px solid var(--panel-border); padding-top: 8px; } .viz-row { display: grid; grid-template-columns: 22px 1fr 88px 52px 22px; align-items: center; gap: 4px; padding: 3px 2px; border-radius: 4px; } .viz-row:hover { background: rgba(255, 255, 255, 0.05); } .viz-row .mini { width: 20px; height: 20px; padding: 0; border: none; background: none; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; text-decoration: none; } .viz-row .mini:hover { color: var(--accent); } .viz-row .mini svg { width: 14px; height: 14px; } .viz-name { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .viz-date { font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; } .viz-size { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } #infoActions { margin-top: 12px; display: flex; gap: 8px; } /* --- Puces d'état (bas d'écran) --- */ .chip { position: fixed; z-index: 900; background: var(--panel); border: 1px solid var(--panel-border); border-radius: 6px; padding: 5px 10px; font-size: 11px; color: var(--muted); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } #zoomIndicator { bottom: 12px; left: 12px; font-variant-numeric: tabular-nums; } #hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; } /* --- Génération de zones --- */ #genBar { bottom: 48px; left: 50%; transform: translateX(-50%); display: none; align-items: center; gap: 12px; padding: 10px 14px; max-width: min(96vw, 900px); flex-wrap: wrap; } #genBar.show { display: flex; } #genMsg { font-size: 12.5px; } .gen-opt { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; } /* File de génération : panneau latéral, le journal replié par défaut */ #genQueue { bottom: 48px; right: 12px; width: 780px; max-width: 94vw; } #genQueueBody { padding: 8px 10px; max-height: 42vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } .gen-queue-actions { display: flex; align-items: center; gap: 8px; } .gen-queue-count { font-size: 11px; color: var(--muted); } .gen-task { border: 1px solid var(--panel-border); border-radius: 6px; } .gen-task-head { display: flex; align-items: center; gap: 8px; padding: 6px 8px; } .gen-task-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--muted); } .gen-task.running .gen-task-dot { background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite; } .gen-task.done .gen-task-dot { background: #39d98a; } .gen-task.failed .gen-task-dot { background: var(--danger); } @keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } .gen-task-label { font-size: 12px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gen-task-state { font-size: 11px; white-space: nowrap; } .gen-task.running .gen-task-state { color: var(--accent); } .gen-task.done .gen-task-state { color: #39d98a; } .gen-task.failed .gen-task-state { color: var(--danger); } .gen-task-time { font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; } .gen-task-toggle { padding: 1px 6px; font-size: 11px; } .gen-task-log { margin: 0 8px 8px; max-height: 160px; overflow: auto; background: #0d1017; border: 1px solid var(--panel-border); border-radius: 6px; padding: 8px 10px; font: 10.5px/1.45 ui-monospace, Menlo, monospace; color: #9fb3d9; white-space: pre-wrap; } body.drawing, body.drawing #viewport { cursor: crosshair !important; } /* --- Responsive --- */ @media (max-width: 720px) { #layerPanel { width: min(280px, 62vw); } #infoPanel { width: min(310px, 66vw); top: auto; bottom: 48px; } #hint { display: none; } } """ _APP_JS = r"""'use strict'; /* Carte LiDAR — couches superposables, infos tuiles, génération de zones. */ // --- Données embarquées par index.py --- // TILES : tuiles affichables (corners WGS84, viz {thumb, full}, meta) // VIZ_META : {clé: {label}} des visualisations disponibles // STATS : {default_viz, n_tiles} const viewport = document.getElementById('viewport'); if (typeof L === 'undefined') { viewport.innerHTML = '
' + "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 }); window.map = map; // accès débogage / automatisations window.TILES = TILES; const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map); // ============================================================ // État des couches (persisté en localStorage) // ============================================================ // order : pile de visualisations, index 0 = bas de pile. const LS_KEY = 'lidarLayers_v1'; function defaultLayers() { const order = Object.keys(VIZ_META); const base = STATS.default_viz && VIZ_META[STATS.default_viz] ? STATS.default_viz : order[0]; const on = {}, opacity = {}; for (const k of order) { on[k] = (k === base); opacity[k] = 1; } return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } }; } function loadLayers() { const def = defaultLayers(); try { const raw = localStorage.getItem(LS_KEY); if (!raw) return def; const saved = JSON.parse(raw); const order = (Array.isArray(saved.order) ? saved.order : []) .filter(k => VIZ_META[k]); for (const k of Object.keys(VIZ_META)) { if (!order.includes(k)) order.push(k); } const on = {}, opacity = {}; for (const k of order) { const op = saved.opacity ? saved.opacity[k] : undefined; on[k] = saved.on && saved.on[k] === true; opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op))) ? 1 : Math.min(1, Math.max(0, Number(op))); } const osm = Object.assign({}, def.osm, saved.osm || {}); return { order, on, opacity, osm }; } catch (e) { return def; } } const state = { layers: loadLayers() }; function saveLayers() { try { localStorage.setItem(LS_KEY, JSON.stringify(state.layers)); } catch (e) { /* privé */ } } function activeLayers() { return state.layers.order.filter(k => state.layers.on[k] && VIZ_META[k]); } // ============================================================ // Marqueurs : une div par tuile, une par couche active // ============================================================ let gridBounds = null; const markers = new Map(); const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0); for (const t of TILES) { if (!t.corners) continue; const center = [(t.corners[0][0] + t.corners[2][0]) / 2, (t.corners[0][1] + t.corners[2][1]) / 2]; const icon = L.divIcon({ className: 'ltile-anchor', html: '
', iconSize: [0, 0] }); const mk = L.marker(L.latLng(center[0], center[1]), { icon: icon, interactive: true, keyboard: false }).addTo(map); mk.on('click', () => { if (!genMode) showTileInfo(t); }); markers.set(tileKey(t), mk); const b = L.latLngBounds(t.corners); gridBounds = gridBounds ? gridBounds.extend(b) : b; } function tileDiv(t) { const mk = markers.get(tileKey(t)); const el = mk && mk.getElement(); return el ? el.querySelector('.ltile') : null; } // Redimensionne + rote chaque tuile selon les coins projetés à l'échelle courante function layoutTiles() { // map.project() renvoie des pixels NON arrondis, contrairement à // latLngToContainerPoint() (arrondi entier) : au zoom global un bord de tuile // de 1 km ne fait que ~0,6 px et l'arrondi l'écrase à 0 (rotations fausses // de 0/45/90°, largeurs 0 ou 1 px, tuiles qui ne s'emboîtent plus). const zoom = map.getZoom(); for (const t of TILES) { const div = tileDiv(t); if (!div) continue; const [sw, se, ne, nw] = t.corners.map(c => map.project(L.latLng(c[0], c[1]), zoom)); const w = Math.hypot(se.x - sw.x, se.y - sw.y); const h = Math.hypot(nw.x - sw.x, nw.y - sw.y); const ang = Math.atan2(se.y - sw.y, se.x - sw.x) * 180 / Math.PI; // Compense l'arrondi entier de la position du marqueur (erreur <= 0,5 px) // pour que le bord partagé de 2 tuiles voisines coïncide exactement. const cx = (sw.x + ne.x) / 2, cy = (sw.y + ne.y) / 2; div.style.marginLeft = (cx - Math.round(cx)) + 'px'; div.style.marginTop = (cy - Math.round(cy)) + 'px'; div.style.width = w + 'px'; div.style.height = h + 'px'; div.style.transform = 'translate(-50%, -50%) rotate(' + ang.toFixed(4) + 'deg)'; } // Resynchronise aussi les : 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 t of TILES) { const div = tileDiv(t); if (!div) continue; div.querySelectorAll('img[data-layer]').forEach(img => { if (!act.includes(img.dataset.layer)) { img.src = ''; img.remove(); } }); act.forEach((k, i) => { if (!t.viz[k]) return; let img = div.querySelector('img[data-layer="' + CSS.escape(k) + '"]'); if (!img) { img = document.createElement('img'); img.dataset.layer = k; img.alt = ''; div.appendChild(img); } img.style.zIndex = String(i + 1); img.style.opacity = String(state.layers.opacity[k]); }); } updateImages(); } // Bascule vignette (256 px) ↔ image pleine résolution selon le zoom : // en vue d'ensemble la vignette suffit ; quand une tuile dépasse ~300 px à // l'écran ET est visible dans le viewport (marge de préchargement), on charge // l'image complète pour conserver les détails. En dézoomant on retombe sur // la vignette pour libérer la mémoire. function updateImages() { const vp = viewport.getBoundingClientRect(); const marge = 300; const act = new Set(activeLayers()); for (const t of TILES) { const div = tileDiv(t); if (!div) continue; const r = div.getBoundingClientRect(); const visible = r.right > vp.left - marge && r.left < vp.right + marge && r.bottom > vp.top - marge && r.top < vp.bottom + marge; const big = visible && (r.width > 300 || r.height > 300); for (const img of div.querySelectorAll('img[data-layer]')) { const k = img.dataset.layer; if (!act.has(k)) continue; const v = t.viz[k]; if (!v) continue; // Les couches quasi transparentes n'ont pas besoin de pleine résolution const want = (big && state.layers.opacity[k] > 0.15) ? v.full : v.thumb; if (!img.src.endsWith(want)) img.src = want; } } } // ============================================================ // Panneau de couches // ============================================================ const SVG_EYE = ''; 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' : ''); row.draggable = true; row.dataset.layer = key; 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 t of TILES) { const div = tileDiv(t); const img = div && div.querySelector('img[data-layer="' + CSS.escape(key) + '"]'); if (img) img.style.opacity = String(v); } updateImages(); }); range.addEventListener('change', saveLayers); row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); }); row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); }); row.addEventListener('dragover', e => e.preventDefault()); row.addEventListener('drop', e => { e.preventDefault(); if (!dragKey || dragKey === key) return; const ord = state.layers.order; ord.splice(ord.indexOf(dragKey), 1); ord.splice(ord.indexOf(key), 0, dragKey); saveLayers(); renderLayerPanel(); syncTileLayers(); }); vizLayersEl.appendChild(row); } } document.getElementById('osmToggle').addEventListener('click', () => { state.layers.osm.on = !state.layers.osm.on; saveLayers(); renderOsmRow(); }); document.getElementById('osmOpacity').addEventListener('input', e => { state.layers.osm.opacity = Number(e.target.value) / 100; osmLayer.setOpacity(state.layers.osm.opacity); document.getElementById('osmPct').textContent = e.target.value + '%'; }); document.getElementById('osmOpacity').addEventListener('change', saveLayers); // Clic sur le nom du fond = bascule mode sombre / clair document.getElementById('osmRow').querySelector('.layer-name') .addEventListener('click', () => { state.layers.osm.dark = !state.layers.osm.dark; saveLayers(); renderOsmRow(); }); document.getElementById('osmRow').querySelector('.layer-name').title = 'Cliquer : fond de carte clair / assombri'; const layerPanel = document.getElementById('layerPanel'); document.getElementById('layerCollapse').addEventListener('click', () => { layerPanel.classList.toggle('collapsed'); try { localStorage.setItem('lidarLayersCollapsed', layerPanel.classList.contains('collapsed') ? '1' : ''); } catch (e) {} }); try { if (localStorage.getItem('lidarLayersCollapsed') === '1') layerPanel.classList.add('collapsed'); } catch (e) {} // ============================================================ // Vue : zoom / pan / persistance (#zoom,lat,lng dans l'URL) // ============================================================ function readViewHash() { const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)$/.exec(location.hash); if (m && !isNaN(parseFloat(m[1])) && !isNaN(parseFloat(m[2])) && !isNaN(parseFloat(m[3]))) { return { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]) }; } return null; } function saveViewHash() { const c = map.getCenter(); history.replaceState(null, '', '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6)); } function fitView() { // Si une vue a été sauvegardée dans l'URL (F5 / lien partagé), on la // restaure au lieu de recadrer sur l'ensemble des tuiles. const v = readViewHash(); if (v) { map.setView([v.lat, v.lng], v.zoom); return; } // Fallback : vue sauvegardée en sessionStorage (après une génération). const saved = sessionStorage.getItem('lidarMap'); if (saved) { try { const v = JSON.parse(saved); if (v.lat != null && v.lng != null && v.zoom != null) { map.setView([v.lat, v.lng], v.zoom); return; } } catch (e) { /* stockage illisible : on retombe sur fitBounds */ } } if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); } const zoomInd = document.getElementById('zoomIndicator'); document.getElementById('zoomIn').addEventListener('click', () => map.zoomIn(1)); document.getElementById('zoomOut').addEventListener('click', () => map.zoomOut(1)); document.getElementById('zoomReset').addEventListener('click', () => { history.replaceState(null, '', location.pathname + location.search); if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); }); map.on('zoomend', () => { zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1); layoutTiles(); }); map.on('moveend', () => { updateImages(); saveViewHash(); }); map.on('click', hideTileInfo); map.whenReady(() => layoutTiles()); // ============================================================ // Panneau d'infos tuile (métadonnées de génération) // ============================================================ const infoPanel = document.getElementById('infoPanel'); const infoTitle = document.getElementById('infoTitle'); const infoBody = document.getElementById('infoBody'); let currentTile = null; function methodLabel(m) { if (!m) return 'inconnue'; if (m === 'ign') return 'IGN — sol (2)'; if (m.startsWith('ign_')) return 'IGN — classes ' + m.split('_').slice(1).join(', '); return { smrf: 'SMRF', csf: 'CSF', auto: 'Auto (détection)' }[m] || m; } function humanSize(bytes) { if (!Number.isFinite(bytes)) return ''; if (bytes < 1024) return bytes + ' o'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' Ko'; return (bytes / (1024 * 1024)).toFixed(1) + ' Mo'; } function layerPos(key) { const i = state.layers.order.indexOf(key); return i === -1 ? 999 : i; } function showTileInfo(t) { currentTile = t; const meta = t.meta || {}; const vizRows = Object.keys(t.viz) .sort((a, b) => layerPos(a) - layerPos(b)) .map(k => { const vm = meta.viz && meta.viz[k]; return '
' + '' + '' + (VIZ_META[k] ? VIZ_META[k].label : k) + '' + '' + (vm ? vm.date : '') + '' + '' + (vm ? humanSize(vm.size) : '') + '' + '⤓' + '
'; }).join(''); infoTitle.textContent = 'Tuile ' + t.col + ',' + t.row; infoBody.innerHTML = '
' + '
Dalle
' + t.name + '
' + '
Coordonnées L93
' + t.col + ' km E · ' + t.row + ' km N
' + '
Résolution
' + t.resolution + ' m/px
' + '
Taille
' + (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km
' + '
Classification sol
' + methodLabel(meta.method) + '
' + '
Générée le
' + (meta.generated || '?') + '
' + '
' + '
Visualisations
' + vizRows + '
' + '' + '
'; infoBody.querySelectorAll('button[data-act="show"]').forEach(btn => { btn.addEventListener('click', () => { const k = btn.dataset.viz; if (!VIZ_META[k]) return; state.layers.on[k] = true; if (state.layers.opacity[k] === 0) state.layers.opacity[k] = 1; saveLayers(); renderLayerPanel(); syncTileLayers(); layerPanel.classList.remove('collapsed'); }); }); const regen = document.getElementById('infoRegen'); if (regen) regen.addEventListener('click', () => regenerateCurrentTile()); // Surlignage de la tuile sélectionnée for (const tk of TILES) { const div = tileDiv(tk); if (div) div.classList.toggle('selected', tk === t); } infoPanel.hidden = false; } function hideTileInfo() { infoPanel.hidden = true; currentTile = null; for (const t of TILES) { const div = tileDiv(t); if (div) div.classList.remove('selected'); } } document.getElementById('infoClose').addEventListener('click', hideTileInfo); document.addEventListener('keydown', e => { if (e.key === 'Escape') hideTileInfo(); }); function regenerateCurrentTile() { if (!currentTile || !apiAvailable) return; const t = currentTile; hideTileInfo(); pendingTiles = [[t.col, t.row]]; pendingRegen = true; document.getElementById('genRegen').checked = true; genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez'; genGo.style.display = ''; genBar.classList.add('show'); } // ============================================================ // Génération de nouvelles zones via l'API web (./run.sh --serve) // ============================================================ let apiAvailable = false; const genBtn = document.getElementById('btnGenZone'); const genBar = document.getElementById('genBar'); const genMsg = document.getElementById('genMsg'); const genGo = document.getElementById('genGo'); const genCancel = document.getElementById('genCancel'); const genRegen = document.getElementById('genRegen'); const genClass = document.getElementById('genClass'); const genIgnClasses = document.getElementById('genIgnClasses'); const genBareEarth = document.getElementById('genBareEarth'); const genQueue = document.getElementById('genQueue'); const genQueueBody = document.getElementById('genQueueBody'); const genQueueClear = document.getElementById('genQueueClear'); const genQueueCount = document.getElementById('genQueueCount'); let genMode = false, drawStart = null, drawRect = null; let pendingTiles = [], pendingRegen = false; const ghostCells = []; fetch('/api/status').then(r => { apiAvailable = r.ok; if (r.ok) genBtn.hidden = false; }).catch(() => {}); function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; } function exitGenMode() { genMode = false; drawStart = null; document.body.classList.remove('drawing'); if (drawRect) { drawRect.remove(); drawRect = null; } map.dragging.enable(); } genBtn.addEventListener('click', () => { if (genMode) { exitGenMode(); return; } clearGhosts(); genBar.classList.remove('show'); genMode = true; map.dragging.disable(); document.body.classList.add('drawing'); genMsg.textContent = 'Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)'; genGo.style.display = 'none'; genBar.classList.add('show'); }); map.on('mousedown', e => { if (!genMode) return; drawStart = e.latlng; if (drawRect) drawRect.remove(); drawRect = L.rectangle([drawStart, drawStart], { color: '#4cc9f0', weight: 2, dashArray: '6 4', fillOpacity: 0.05 }).addTo(map); }); map.on('mousemove', e => { if (!genMode || !drawStart || !drawRect) return; drawRect.setBounds(L.latLngBounds(drawStart, e.latlng)); }); map.on('mouseup', e => { if (!genMode || !drawStart) return; const b = L.latLngBounds(drawStart, e.latlng); drawStart = null; if (Math.abs(b.getWest() - b.getEast()) < 1e-9 || Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return; exitGenMode(); genMsg.textContent = 'Analyse de la zone...'; genGo.style.display = 'none'; genBar.classList.add('show'); fetch('/api/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], regenerate: genRegen.checked }) }).then(r => r.json()).then(d => { if (drawRect) { drawRect.remove(); drawRect = null; } pendingTiles = (d.cells || []).map(c => [c.col, c.row]); (d.cells || []).forEach(c => ghostCells.push( L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map) )); if (!pendingTiles.length) { genMsg.textContent = 'Aucune tuile manquante dans cette zone' + (genRegen.checked ? '' : ' (cochez « Régénérer » pour traiter les tuiles existantes)'); genGo.style.display = 'none'; return; } genMsg.textContent = pendingTiles.length + ' tuile(s) à ' + (genRegen.checked ? 'régénérer' : 'télécharger et générer') + (d.capped ? ' (zone tronquée à 400)' : ''); genGo.style.display = ''; }).catch(() => { genMsg.textContent = 'API indisponible'; }); }); genGo.addEventListener('click', () => { if (!pendingTiles.length) return; genBar.classList.remove('show'); clearGhosts(); const nTiles = pendingTiles.length; const isRegen = genRegen.checked || pendingRegen; fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tiles: pendingTiles, regenerate: isRegen, ground_class: genClass ? genClass.value : 'ign', ign_classes: genIgnClasses ? genIgnClasses.value : 'sol', bare_earth: genBareEarth ? genBareEarth.checked : false }) }).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) .then(() => startGenTask(nTiles + ' tuile(s) — ' + (isRegen ? 'régénération' : 'génération'))) .catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); }); }); genCancel.addEventListener('click', () => { genBar.classList.remove('show'); clearGhosts(); pendingTiles = []; pendingRegen = false; if (genMode) exitGenMode(); }); // ============================================================ // File de génération : panneau latéral persistant (sessionStorage). // Les tâches survivent au rechargement de la carte : une tâche finie // reste affichée « Terminé » jusqu'au vidage manuel de la liste. // ============================================================ const GEN_QKEY = 'lidarGenQueue'; let genTasks = []; try { genTasks = JSON.parse(sessionStorage.getItem(GEN_QKEY) || '[]'); } catch (e) { genTasks = []; } let genPollTimer = null; function saveGenTasks() { try { sessionStorage.setItem(GEN_QKEY, JSON.stringify(genTasks)); } catch (e) { /* privé */ } } function genTaskEl(t) { const el = document.createElement('div'); el.className = 'gen-task ' + t.state; const head = document.createElement('div'); head.className = 'gen-task-head'; const dot = document.createElement('span'); dot.className = 'gen-task-dot'; const label = document.createElement('span'); label.className = 'gen-task-label'; label.title = t.label; label.textContent = t.label; const state = document.createElement('span'); state.className = 'gen-task-state'; state.textContent = t.state === 'running' ? 'En cours…' : t.state === 'done' ? 'Terminé' : 'Échec (code ' + t.returncode + ')'; const time = document.createElement('span'); time.className = 'gen-task-time'; time.textContent = t.time; const toggle = document.createElement('button'); toggle.className = 'icon-btn gen-task-toggle'; toggle.textContent = t.logOpen ? '⌃' : '⌄'; toggle.title = 'Afficher / masquer le journal'; head.append(dot, label, state, time, toggle); el.appendChild(head); const log = document.createElement('pre'); log.className = 'gen-task-log'; log.hidden = !t.logOpen; log.textContent = (t.log || []).join('\n') || 'Démarrage...'; log.scrollTop = log.scrollHeight; el.appendChild(log); toggle.addEventListener('click', () => { t.logOpen = !t.logOpen; saveGenTasks(); renderGenQueue(); }); return el; } function renderGenQueue() { genQueueBody.textContent = ''; // Plus récentes en haut for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t)); const running = genTasks.filter(t => t.state === 'running').length; genQueueCount.textContent = genTasks.length ? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)') : ''; genQueue.hidden = genTasks.length === 0; } function startGenTask(labelText) { const now = new Date(); genTasks.push({ id: Date.now(), label: labelText, time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }), state: 'running', returncode: null, log: [], logOpen: false, }); saveGenTasks(); renderGenQueue(); pollGenStatus(); } function finishRunningTasks(d) { let changed = false; for (const t of genTasks) { if (t.state !== 'running') continue; t.state = d.returncode === 0 ? 'done' : 'failed'; t.returncode = d.returncode; changed = true; } if (changed) saveGenTasks(); renderGenQueue(); } function pollGenStatus() { if (genPollTimer) return; // un seul sondage global pour toute la file genPollTimer = setInterval(() => { fetch('/api/status').then(r => r.json()).then(d => { const running = genTasks.filter(t => t.state === 'running'); if (!running.length) { clearInterval(genPollTimer); genPollTimer = null; return; } const lines = d.log || []; for (const t of running) t.log = lines.slice(-60); if (!d.running) { clearInterval(genPollTimer); genPollTimer = null; finishRunningTasks(d); // Sauvegarde la vue courante pour qu'elle survive au rechargement // (sinon fitView() recadre sur l'ensemble des tuiles et le zoom part à zéro). try { const c = map.getCenter(); sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() })); } catch (e) { /* sessionStorage indisponible : pas grave */ } setTimeout(() => location.reload(), 2500); } else { saveGenTasks(); renderGenQueue(); } }).catch(() => {}); }, 2000); } genQueueClear.addEventListener('click', () => { genTasks = genTasks.filter(t => t.state === 'running'); saveGenTasks(); renderGenQueue(); }); renderGenQueue(); // Reprend le suivi si une tâche tournait avant un rechargement de la page if (genTasks.some(t => t.state === 'running')) pollGenStatus(); // ============================================================ // Initialisation // ============================================================ renderOsmRow(); renderLayerPanel(); syncTileLayers(); fitView(); layoutTiles(); zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1); } // fin guard Leaflet """