Files
lidar_rendu/lidar_pipeline/index.py

4193 lines
177 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 via l'API web (./run.sh --serve) : dessin d'un rectangle
(téléchargement IGN + traitement) ou sélection cumulative au clic —
chaque tuile individuelle, avec ou sans données existantes ; les zones
et les clics s'additionnent, et une demande lancée pendant un run part
en file d'attente côté serveur (jamais de coupure du travail en place).
- 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 hashlib
import html
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%)\nÉchelle physique commune à toutes les tuiles (références\nfigées mesurées sur dalles réelles) : mosaïque jointive,\nmême valeur = même couleur',
'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 : une couche opaque (opacité 1)
# doit rester en bas pour ne pas masquer les autres.
DEFAULT_LAYERS = ('positive_openness', 'aspect')
# Opacité par défaut des couches allumées (0–1) ; les absentes restent à 1.
DEFAULT_OPACITY = {'aspect': 0.6}
# Mode de fusion (mix-blend-mode CSS) par couche : les couches d'ombrage en
# niveaux de gris fondent en « multiply » — elles assombrissent le relief sans
# délaver la rampe de couleurs en dessous, contrairement à la transparence.
# Les couches absentes restent en « normal » (simple transparence).
DEFAULT_BLEND = {'hillshade_multi': 'multiply', 'solar': 'multiply', 'svf': 'multiply'}
# 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 ci-dessous 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). La pente y reste proposée mais éteinte par défaut.
PANEL_VIZ = ('slope', 'aspect', 'positive_openness')
# 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
# Vignette de sous-tuile (px) : à la vue d'ensemble chaque sous-tuile active
# décode sa vignette en navigateur — 160 px couvre l'affichage jusqu'à ~220 px
# à l'écran et réduit la mémoire décodée d'un facteur ~2,5 vs 256 px. La taille
# est encodée dans le nom de fichier : la changer invalide le cache.
_SUBTILE_THUMB_PX = 160
# 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 (résolution, row décroissant, 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 = [{'tiles': zone_tiles, 'bbox': compute_bbox(zone_tiles)}
for zone_tiles in zone_members.values()]
# Trier AVANT de numéroter : les libellés « Zone N » suivent l'ordre
# d'affichage (taille décroissante)
zones.sort(key=lambda z: len(z['tiles']), reverse=True)
for i, z in enumerate(zones, 1):
z['label'] = f'Zone {i} ({len(z["tiles"])} tuiles)'
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)
with PILImage.open(str(src_path)) as _src_img:
img = _src_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)
thumb_suffix = f"_thumb{_SUBTILE_THUMB_PX}.webp"
def _save_thumb(quad, stem):
"""Écrit la vignette de sous-tuile (redimensionnée à _SUBTILE_THUMB_PX)."""
scale = min(1.0, _SUBTILE_THUMB_PX / max(quad.size))
out = quad
if scale < 1.0:
out = quad.resize((max(1, int(quad.size[0] * scale)),
max(1, int(quad.size[1] * scale))), resample)
out.save(str(output_dir / sub_dir_name / (stem + thumb_suffix)),
format='WEBP', quality=80)
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)
def _fresh(name):
return _cached_file_fresh(output_dir / sub_dir_name / name, src_mtime)
# AVIF + vignette intermédiaire d'un côté, vignettes de l'autre :
# un changement de taille de vignette (dalle source inchangée)
# re-découpe depuis les AVIF de sous-tuiles déjà présents, sans
# ré-encoder ces derniers (des minutes de CPU par rebuild).
heavy_fresh = all(_fresh(stem + '.avif') and _fresh(stem + '_mid.webp')
for stem in stems.values())
thumbs_fresh = all(_fresh(stem + thumb_suffix) for stem in stems.values())
if heavy_fresh and not thumbs_fresh:
logger.info(f" Vignettes de sous-tuiles recalculées : "
f"{tile['dir_name']}/{viz_key} ({len(stems)} découpages)")
try:
for stem in stems.values():
with PILImage.open(str(output_dir / sub_dir_name / (stem + '.avif'))) as q:
q.load()
if q.mode not in ('RGB', 'L'):
q = q.convert('RGB')
_save_thumb(q, stem)
# Vignette 256 px d'une génération précédente
(output_dir / sub_dir_name / (stem + '_thumb.webp')).unlink(missing_ok=True)
except Exception as e:
logger.debug(f"Re-découpe des vignettes impossible ({viz_key}) : {e}")
heavy_fresh = False
if not heavy_fresh:
logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} "
f"({len(stems)} découpages)")
img = None
for attempt in range(2):
try:
img = PILImage.open(str(src))
img.load()
break
except Exception as e:
# Rebuild incrémental pendant un run : la dalle source
# peut être en cours d'écriture par un worker voisin
# (lecture partielle). Une tentative après une courte
# pause suffit presque toujours.
if attempt == 0:
time.sleep(2.0)
continue
logger.warning(f"Sous-tuilage {viz_key} impossible après "
f"nouvelle tentative ({src.name}) : {e}")
_fallback_full_dalle(entries, viz_key, info)
if img is None:
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 les anciens crops d'une génération précédente
# (repli .webp, vignette 256 px sans taille dans le nom)
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
(output_dir / sub_dir_name / (stem + '_thumb.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)
_save_thumb(quad, stem)
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_suffix}{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),
'default_opacity': dict(DEFAULT_OPACITY),
'default_blend': dict(DEFAULT_BLEND),
'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_ui_assets(assets_dir):
"""Écrit les fichiers statiques de l'interface (CSS + JS) dans `assets_dir`.
Inclut Leaflet vendorisé (vendor/leaflet) : la carte reste fonctionnelle
hors ligne, sans dépendance au CDN unpkg. La webapp bâche ces fichiers
dans l'image (cf. Dockerfile.webapp) ; le mode serveur statique les écrit
dans output/assets/ (cf. _write_assets).
"""
import shutil
assets_dir = Path(assets_dir)
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 _write_assets(output_dir):
"""Écrit les fichiers statiques de l'interface dans output/assets/."""
_write_ui_assets(Path(output_dir) / 'assets')
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 _json_for_script(obj):
"""JSON sûr à embarquer dans un <script> inline.
json.dumps n'échappe pas « </ » : un nom de dalle contenant
« </script><script>… » sortirait du contexte JS (nom de dossier issu
d'un fichier renommé dans input/). La séquence est neutralisée par un
antislash devant la barre oblique, ce qui est identique pour le parseur
JSON.
"""
return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
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_for_script(tiles)
# Les assets sont référencés avec une version (hash de leur contenu) :
# une URL neuve à chaque évolution du CSS/JS force le rechargement chez
# tous les navigateurs, même ceux qui ont une entrée de cache antérieure
# aux en-têtes no-cache de la webapp.
assets_v = hashlib.sha1(
(_APP_CSS + _APP_JS).encode('utf-8')).hexdigest()[:10]
# 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' <option value="{html.escape(KEYWORD_TO_STEP.get(v, v))}" selected>'
f'{html.escape(VIZ_LABELS.get(v, v))}</option>'
for v in gen_keys)
viz_meta_json = _json_for_script({
k: {'label': VIZ_LABELS.get(k, k)}
for k in ordered_viz
})
stats_json = _json_for_script(
{'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys),
'default_opacity': dict(DEFAULT_OPACITY),
'default_blend': dict(DEFAULT_BLEND),
'n_tiles': len(tiles)},
)
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,
assets_v=assets_v,
gen_viz_options=gen_viz_options,
n_tiles=n_tiles,
grid_w=grid_w,
grid_h=grid_h,
output_format=output_format.upper(),
)
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte LiDAR</title>
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css"/>
<link rel="stylesheet" href="assets/app.css?v={assets_v}"/>
</head>
<body>
<div id="viewport"></div>
<!-- Barre latérale unique : marque + onglets Couches / Génération -->
<aside id="sidebar" class="panel">
<div class="sidebar-head">
<div class="head-row">
<h1>Carte LiDAR</h1>
<button id="sidebarCollapse" class="icon-btn" title="Replier / déplier le panneau">◀</button>
</div>
<span class="stat" id="statTiles">{n_tiles} tuile(s) · {grid_w}×{grid_h} km · {output_format}</span>
</div>
<div class="sidebar-body">
<div class="tabbar">
<button class="tab active" data-tab="layers">Couches</button>
<button class="tab" data-tab="gen">Génération<span id="genBadge" class="tab-badge" hidden></span></button>
<button class="tab" data-tab="tile">Tuile</button>
<button class="tab" data-tab="export">Export</button>
</div>
<div class="tab-page" id="tab-layers">
<div class="layer-row" id="osmRow">
<button class="eye on" id="osmToggle" title="Afficher / masquer le fond de carte"></button>
<span class="layer-name">Fond de carte OSM</span>
<input type="range" id="osmOpacity" min="0" max="100" step="1" title="Opacité du fond">
<span class="layer-pct" id="osmPct"></span>
</div>
<div class="layer-sub">
<span>Visualisations · glisser ⠿ pour réordonner</span>
<span class="layer-sub-actions">
<button id="btnShare" class="icon-btn" title="Copier un lien partageant la vue, les couches et leurs transparences">⎘ Partager</button>
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
</span>
</div>
<div class="preset-row">
<select id="presetSelect" class="gen-select" title="Préconfigurations : couches visibles, niveaux de transparence (visualisations + fond de carte) et ordre de pile">
<option value="">Préconfigurations…</option>
</select>
<button id="presetSave" class="icon-btn" title="Enregistrer l'état actuel (couches, niveaux de transparence, ordre) comme preset">💾</button>
<button id="presetDelete" class="icon-btn" hidden title="Supprimer le preset sélectionné">✕</button>
</div>
<div id="vizLayers"></div>
</div>
<div class="tab-page" id="tab-gen" hidden>
<!-- Actions Zone / Sélection / Compléter : réservées à cet onglet -->
<div class="gen-actions">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnGenSel" title="Sélectionner des tuiles précises, à générer ou régénérer : un clic sur la carte ajoute ou retire la dalle (les zones dessinées s'ajoutent à la sélection)" hidden>Sélectionner des tuiles</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
</div>
<!-- Confirmation de génération (options + lancement) -->
<div id="genBar">
<div id="genMsg"></div>
<label class="gen-opt">
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
</label>
<label class="gen-opt" title="Par défaut la classification du sol existante est conservée (DTM réutilisé). Cochez pour la relancer sur toutes les tuiles ; choisir une autre méthode ci-dessous reclassifie déjà les tuiles concernées.">
<input type="checkbox" id="genReclass"> Reclasser le sol
</label>
<div class="gen-hint" id="genClassInfo" hidden></div>
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
<span>Visualisations :</span>
<select id="genViz" class="gen-select" multiple size="8">
{gen_viz_options}
</select>
</label>
<label class="gen-opt col">
<span>Classification :</span>
<select id="genClass" class="gen-select">
<option value="auto">Auto (détection)</option>
<option value="ign" selected>IGN (pré-classification)</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select>
<select id="genIgnClasses" class="gen-select" title="Classes LAS extraites pour le MNT (méthode IGN, mode pur)">
<option value="sol" selected>Sol (2)</option>
<option value="sol,unclassified">Sol + non-classé (2+1)</option>
<option value="sol,unclassified,eau,virtuel">Sol+non-classé+eau+virtuel (2+1+9+66)</option>
</select>
</label>
<label class="gen-opt" title="Complète les bords du MNT avec les points sol des 8 tuiles voisines (bande de 100 m) : les rendus à grand noyau (openness, SVF, LRM) deviennent continus d'une tuile à l'autre. Les images restent recadrées sur la dalle 1 km exacte. Régénère le MNT des tuiles concernées (~1 min de lecture des voisines par tuile en plus).">
<input type="checkbox" id="genEdgeBuffer"> Raccord des bords (tuiles voisines)
</label>
<div class="gen-run">
<button id="genGo" class="primary">Lancer</button>
<button id="genCancel">Annuler</button>
</div>
</div>
<!-- File de génération : dans l'onglet, plus en surimpression -->
<div id="genQueue" hidden>
<div class="queue-head">
<span class="panel-title">File de génération</span>
<span id="genQueueCount"></span>
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours (la file d'attente n'est pas touchée)" hidden>■</button>
<button id="genQueueDrop" class="icon-btn" title="Retirer les demandes en attente (elles démarreraient après le run en cours sinon)" hidden>⏏</button>
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div>
<div id="genQueueBody"></div>
</div>
</div>
<div class="tab-page" id="tab-tile" hidden>
<div id="tileBody">
<div class="tile-empty">Cliquez sur une tuile de la carte pour afficher ses informations de génération.</div>
</div>
</div>
<div class="tab-page" id="tab-export" hidden>
<div class="export-intro">
Assemblez des dalles adjacentes en une image ou un PDF consultable
hors ligne sur téléphone (collage bord à bord, sans séparation).
</div>
<div class="gen-actions">
<button id="btnExportSel" title="Activer la sélection : cliquer des dalles sur la carte pour les ajouter ou les retirer">⤓ Sélectionner des dalles</button>
<button id="exportClear" hidden>Effacer la sélection</button>
</div>
<div id="exportBar">
<div id="exportCount" class="export-count"></div>
<label class="gen-opt col">
<span>Résolution :</span>
<select id="exportRes" class="gen-select"></select>
</label>
<label class="gen-opt col">
<span>Visualisations <em>(une image/page par couche)</em> :</span>
<select id="exportViz" class="gen-select" multiple size="5"></select>
</label>
<label class="gen-opt col">
<span>Format :</span>
<select id="exportFormat" class="gen-select">
<option value="jpeg" selected>JPEG — image légère</option>
<option value="png">PNG — image sans perte</option>
<option value="webp">WebP — image</option>
<option value="pdf">PDF — une page par visualisation</option>
</select>
</label>
<label class="gen-opt col">
<span>Taille (plus grand côté) :</span>
<select id="exportSize" class="gen-select">
<option value="0">Pleine résolution</option>
<option value="4096" selected>4096 px — téléphone</option>
<option value="2048">2048 px — aperçu</option>
</select>
</label>
<div class="gen-run">
<button id="exportGo" class="primary">Exporter</button>
</div>
<div id="exportMsg"></div>
</div>
<div id="exportDone" hidden>
<div class="tile-info-sub">Exports de la session</div>
<div id="exportDoneList"></div>
</div>
</div>
</div>
</aside>
<nav id="mapTools" class="panel">
<button id="btnMenu" title="Afficher / masquer le panneau">☰</button>
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
<button id="btnGps" title="Centrer la carte sur la position GPS du téléphone">⌖</button>
</nav>
<div id="zoomIndicator" class="chip">Zoom</div>
<div id="gpsToast" class="chip" hidden></div>
<div id="hint" class="chip">
Molette : zoom · Clic-glisser : déplacer<br>
Clic sur tuile : infos de génération
</div>
<script>
const TILES = {tiles_json};
const VIZ_META = {viz_meta_json};
const STATS = {stats_json};
</script>
<script src="assets/vendor/leaflet/leaflet.js"></script>
<script src="assets/app.js?v={assets_v}"></script>
</body>
</html>
"""
_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */
:root {
/* Le color-scheme impose au navigateur le rendu sombre des contrôles
natifs (popup des <select>, barres de défilement) : sans cela, le
menu déroulant des préconfigurations s'affiche en thème clair sur
une interface sombre. */
color-scheme: dark;
--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;
}
.gen-select:disabled { opacity: 0.4; cursor: not-allowed; }
/* Options du menu déroulant : fallback pour les navigateurs sans
color-scheme (le popup natif hérite de ces couleurs). */
.gen-select option { background-color: #10141b; color: var(--text); }
/* --- 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;
}
/* Ligne allumée : un sélecteur de mode de fusion s'insère avant le slider
(les lignes éteintes gardent la grille large pour un nom lisible). */
.layer-row.on { grid-template-columns: 16px 24px minmax(0, 1fr) 64px 62px 24px; }
.layer-blend {
font-size: 10.5px; color: var(--muted); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--panel-border); border-radius: 4px;
padding: 2px 2px; min-width: 0; width: 100%; height: 22px;
}
.layer-blend:hover { color: var(--text); }
#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; }
.preset-row {
display: flex; align-items: center; gap: 4px;
padding: 4px 6px;
}
.preset-row .gen-select { flex: 1; min-width: 0; }
.preset-row .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;
/* Grille dessinée en outline (par-dessus le bord des images) : une
bordure classique insetterait l'image de 1 px (box-sizing: border-box)
et creuserait un espace vide entre deux tuiles jointives. */
outline: 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;
/* Les modes de fusion (mix-blend-mode) des <img> se cantonnent à la pile de
couches de la tuile : sans isolation, « multiply » assombrirait aussi le
fond de carte OSM sous les zones sans données. */
isolation: isolate;
/* 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) : le pane
des tuiles porte le scaling — promotion GPU de lui seul (et non des ~1000
div de tuiles), puis libération pour ne pas immobiliser de mémoire. */
body.zooming .leaflet-lidarTiles-pane { 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 { outline-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); }
.ltile.selected { outline-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.queued .gen-task-dot {
background: var(--muted); animation: gen-pulse 2.2s 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.queued .gen-task-state { color: var(--muted); }
.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 {
outline-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 {
outline-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; }
/* Cadres des dalles du run de génération (pane genDraw) : seule la dalle
active pulse ; attente (bleu pointillé) et échec (rouge) restent statiques,
posés en JS par updateRenderFrames(). */
.render-frame.render-running { animation: frame-pulse 1.4s ease-in-out infinite; }
@keyframes frame-pulse { 0%, 100% { stroke-opacity: 1; } 50% { stroke-opacity: 0.3; } }
/* 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, default_layers, default_opacity, n_tiles}
const viewport = document.getElementById('viewport');
if (typeof L === 'undefined') {
viewport.innerHTML =
'<div style="padding:24px;color:#e94560;font-family:sans-serif;font-size:14px">' +
"Erreur : Leaflet n'a pas pu être chargé (accès Internet requis pour ouvrir cette page en ligne)." +
'</div>';
} else {
// ============================================================
// Carte Leaflet : zoom, pan, fond OSM
// ============================================================
const map = L.map('viewport', {
zoomControl: false,
attributionControl: false,
minZoom: 4,
maxZoom: 20,
// Les marqueurs ne doivent pas glisser individuellement pendant le zoom :
// le pane lidarTiles est mis à l'échelle d'un bloc (voir zoomanim ci-dessous),
// le glissement propre des icônes s'ajouterait au scaling (double mouvement).
// Seul effet collatéral : le marqueur GPS saute à la fin d'un zoom.
markerZoomAnimation: false
});
// Pane dédié aux tuiles LiDAR (marqueurs) : pendant l'animation de zoom il
// reçoit la même mise à l'échelle que le fond de carte (translate + scale,
// transition CSS partagée via la classe leaflet-zoom-animated) — voir le
// gestionnaire zoomanim plus bas. La classe .leaflet-zoom-animated fournit
// aussi transform-origin: 0 0 (feuille de style Leaflet).
const lidarTilesPane = map.createPane('lidarTiles');
lidarTilesPane.style.zIndex = 600;
lidarTilesPane.classList.add('leaflet-zoom-animated');
// Pane dédié au dessin de zones : les tuiles LiDAR vivent en dessous (pane
// lidarTiles, 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', {
// OSM s'arrête à z19 : au-delà Leaflet agrandit les tuiles z19
// (maxNativeZoom) au lieu de demander des tuiles inexistantes.
maxZoom: 20,
maxNativeZoom: 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.
// blend : mode de fusion CSS (mix-blend-mode) de chaque couche — les couches
// se fondent entre elles au sein de la tuile (isolation: isolate sur .ltile)
// au lieu de simplement se superposer en transparence.
const LS_KEY = 'lidarLayers_v1';
// Modes de fusion proposés : [valeur CSS, libellé]. « multiply » assombrit le
// relief (ombrage par-dessus une rampe), « overlay » / « soft-light » creusent
// le contraste local, « screen » éclaircit (openness négative sur fond sombre).
const BLEND_MODES = [
['normal', 'Normal'],
['multiply', 'Produit'],
['overlay', 'Superposé'],
['soft-light', 'Doux'],
['screen', 'Écran'],
];
const BLEND_KEYS = BLEND_MODES.map(m => m[0]);
const blendLabel = v => {
const m = BLEND_MODES.find(x => x[0] === v);
return m ? m[1] : 'Normal';
};
const validBlend = v => BLEND_KEYS.includes(v) ? v : null;
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 defOrder = list.filter(k => VIZ_META[k]);
// La pile respecte l'ordre de default_layers (bas → haut) : une couche
// opaque (opacité 1) doit rester sous les semi-transparentes.
for (let i = 1; i < defOrder.length; i++) {
const bi = order.indexOf(defOrder[i - 1]), ai = order.indexOf(defOrder[i]);
if (bi >= 0 && ai >= 0 && bi > ai) {
order.splice(bi, 1);
order.splice(order.indexOf(defOrder[i]), 0, defOrder[i - 1]);
}
}
// Opacités par défaut (STATS.default_opacity) ; absentes → 1.
const defOp = (STATS.default_opacity && typeof STATS.default_opacity === 'object')
? STATS.default_opacity : {};
// Modes de fusion par défaut (STATS.default_blend) ; absents → normal.
const defBl = (STATS.default_blend && typeof STATS.default_blend === 'object')
? STATS.default_blend : {};
const onSet = new Set(defOrder);
const on = {}, opacity = {}, blend = {};
for (const k of order) {
on[k] = onSet.has(k);
const op = Number(defOp[k]);
opacity[k] = Number.isNaN(op) ? 1 : Math.min(1, Math.max(0, op));
blend[k] = validBlend(defBl[k]) || 'normal';
}
return { order, on, opacity, blend, 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 = {}, blend = {};
const savedBlend = (saved.blend && typeof saved.blend === 'object') ? saved.blend : {};
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)));
blend[k] = validBlend(savedBlend[k]) || def.blend[k] || 'normal';
}
st = { order, on, opacity, blend, osm: Object.assign({}, def.osm, saved.osm || {}) };
}
} catch (e) { st = def; }
// Lien partagé (vue + couches + transparences) : il prime sur l'état local.
// Le lien ne transporte pas le mode de fusion : repli sur les défauts.
if (sharedHash) {
if (sharedHash.layers) {
st = sharedHash.layers;
if (!st.blend || typeof st.blend !== 'object') st.blend = {};
for (const k of st.order) {
if (!validBlend(st.blend[k])) st.blend[k] = def.blend[k] || 'normal';
}
}
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 <img> par couche active.
// Chaque entrée met en cache div + <img> par couche + projections :
// les parcours déclenchés par moveend/zoomend ne font ni querySelector
// ni lecture de layout (getBoundingClientRect).
// ============================================================
let gridBounds = null;
const markers = new Map();
const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0);
// Crée le marqueur Leaflet d'une tuile (utilisé à l'init et en direct,
// quand une tuile terminée apparaît en cours de génération).
function registerTile(t) {
if (!t.corners) return;
const center = [(t.corners[0][0] + t.corners[2][0]) / 2,
(t.corners[0][1] + t.corners[2][1]) / 2];
const icon = L.divIcon({
className: 'ltile-anchor',
html: '<div class="ltile"></div>',
iconSize: [0, 0]
});
const mk = L.marker(L.latLng(center[0], center[1]), {
icon: icon,
interactive: true,
keyboard: false,
pane: 'lidarTiles'
}).addTo(map);
mk.on('click', () => {
if (exportMode) { toggleExportSel(t); return; }
if (genSelMode) { toggleGenSelCell(t.col, t.row, t.corners); 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 <img> : au premier rendu les marqueurs viennent
// tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés.
syncTileLayers();
}
// Crée / supprime les <img> de chaque tuile selon les couches actives,
// applique opacité et ordre d'empilement (z-index).
function syncTileLayers() {
const act = activeLayers();
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (!div) continue;
for (const k of Object.keys(entry.imgs)) {
if (act.includes(k)) continue;
entry.imgs[k].src = '';
entry.imgs[k].remove();
delete entry.imgs[k];
}
act.forEach((k, i) => {
if (!entry.t.viz[k]) return;
let img = entry.imgs[k];
if (!img) {
img = document.createElement('img');
img.dataset.layer = k;
img.alt = '';
img.decoding = 'async';
img.loading = 'lazy';
div.appendChild(img);
entry.imgs[k] = img;
}
img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]);
img.style.mixBlendMode = state.layers.blend[k] || 'normal';
});
}
updateImages();
}
// Bascule vignette ↔ vignette intermédiaire (640 px) ↔ image pleine
// résolution selon le zoom, avec trois garde-fous mémoire :
// - hors de l'écran (+ marge de préchargement), l'<img> est vidée de son
// src : ni téléchargement ni décodage pour les tuiles éloignées (et à
// l'ouverture de la page, seules les tuiles visibles se chargent) ;
// - la pleine résolution (≈ 25 Mo décodés par AVIF 2500²) ne se charge
// que pour les sous-tuiles à l'écran — l'anneau de 150 px ne précharge
// que le palier intermédiaire, pour que les tuiles qui entrent à l'écran
// lors d'un pan soient déjà nettes ;
// - le nombre d'images pleine résolution est plafonné (FULL_CAP) : au-delà,
// les sous-tuiles les plus éloignées du centre retombent sur le palier
// intermédiaire. En dézoomant, chaque palier libère la mémoire décodée.
// Les changements de palier passent par setImgSrc : l'image est pré-décodée
// en tâche de fond puis échangée d'un coup — jamais de flash blanc au zoom
// ou au pan. Visibilité et tailles sont calculées en coordonnées projetées :
// aucun reflow.
const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran
const MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide)
const BIG_PX = 220; // en dessous, la vignette suffit
const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire
const FULL_CAP = 24; // images pleine résolution max, toutes couches
// Change le src d'une <img> sans flash : l'image est d'abord téléchargée et
// décodée hors écran, le src n'est posé qu'une fois prête (le cache du
// navigateur sert alors la bitmap décodée instantanément). dataset.want
// invalide l'échange si un updateImages plus récent a décidé d'un autre
// palier entre-temps (pan rapide pendant le décodage).
function setImgSrc(img, url) {
if (img.src.endsWith(url)) { img.dataset.want = url; return; }
img.dataset.want = url;
const pre = new Image();
pre.decoding = 'async';
pre.src = url;
const apply = () => { if (img.dataset.want === url) img.src = url; };
if (pre.decode) pre.decode().then(apply, apply);
else apply();
}
function updateImages() {
if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire
const zoom = map.getZoom();
const vp = map.getBounds();
const swP = map.project(vp.getSouthWest(), zoom);
const neP = map.project(vp.getNorthEast(), zoom);
const ctrP = L.point((swP.x + neP.x) / 2, (swP.y + neP.y) / 2);
const expand = (margin) => L.latLngBounds(
map.unproject(L.point(swP.x - margin, swP.y + margin), zoom),
map.unproject(L.point(neP.x + margin, neP.y - margin), zoom));
const visBounds = expand(PRELOAD_MARGIN_PX);
const midBounds = expand(MID_MARGIN_PX);
// Candidats pleine résolution : triés du plus proche au plus lointain du
// centre, seuls les FULL_CAP premiers l'obtiennent.
const fullWant = [];
const plan = [];
for (const entry of markers.values()) {
const visible = visBounds.intersects(entry.bounds);
const onScreen = vp.intersects(entry.bounds);
const near = midBounds.intersects(entry.bounds);
const p = tileSize(entry);
const big = near && (p.w > BIG_PX || p.h > BIG_PX);
const dist = map.project(entry.bounds.getCenter(), zoom).distanceTo(ctrP);
for (const k in entry.imgs) {
const img = entry.imgs[k];
const v = entry.t.viz[k];
if (!visible || !v) { plan.push([img, null]); continue; }
let want = v.thumb;
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
if (big && state.layers.opacity[k] > 0.15) {
if (!onScreen) {
want = v.mid || v.thumb; // anneau : préchargement intermédiaire
} else if (Math.max(p.w, p.h) < FULL_RES_PX) {
want = v.mid || v.full;
} else {
fullWant.push({ img: img, v: v, dist: dist });
continue;
}
}
plan.push([img, want]);
}
}
fullWant.sort((a, b) => a.dist - b.dist);
fullWant.forEach((c, i) => plan.push(
[c.img, i < FULL_CAP ? c.v.full : (c.v.mid || c.v.thumb)]));
for (const pair of plan) {
const img = pair[0], want = pair[1];
if (!want) {
img.dataset.want = '';
if (img.getAttribute('src') !== null) img.removeAttribute('src');
} else {
setImgSrc(img, want);
}
}
}
// ============================================================
// Panneau de couches
// ============================================================
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const vizLayersEl = document.getElementById('vizLayers');
let dragKey = null;
function renderOsmRow() {
const { on, opacity, dark } = state.layers.osm;
const toggle = document.getElementById('osmToggle');
const slider = document.getElementById('osmOpacity');
const pct = document.getElementById('osmPct');
toggle.classList.toggle('on', on);
toggle.innerHTML = on ? SVG_EYE : SVG_EYE_OFF;
slider.value = Math.round(opacity * 100);
pct.textContent = Math.round(opacity * 100) + '%';
if (on) { if (!map.hasLayer(osmLayer)) osmLayer.addTo(map); }
else { if (map.hasLayer(osmLayer)) map.removeLayer(osmLayer); }
osmLayer.setOpacity(opacity);
viewport.classList.toggle('bright', !dark);
}
function renderLayerPanel() {
vizLayersEl.innerHTML = '';
// Affichage haut de pile en premier (le tableau order est bas → haut).
const topFirst = state.layers.order.slice().reverse();
for (const key of topFirst) {
const meta = VIZ_META[key];
if (!meta) continue;
const row = document.createElement('div');
row.className = 'layer-row' + (state.layers.on[key] ? ' on' : '');
// Le glisser-déposer ne part que de la poignée ⠿ : sinon le drag natif
// de la ligne avale le mousedown des sliders (opacité non manipulable
// à la souris) et les clics sur les boutons.
row.draggable = false;
row.dataset.layer = key;
row.addEventListener('mousedown', e => {
row.draggable = !!(e.target.closest && e.target.closest('.layer-grip'));
});
row.title = meta.label;
const curBlend = state.layers.blend[key] || 'normal';
// Sélecteur de mode de fusion seulement sur les couches allumées :
// les lignes éteintes gardent la grille large (nom lisible).
const blendSel =
'<select class="layer-blend" title="Mode de fusion avec les couches en dessous">' +
BLEND_MODES.map(m =>
'<option value="' + m[0] + '"' + (m[0] === curBlend ? ' selected' : '') + '>' +
m[1] + '</option>').join('') +
'</select>';
row.innerHTML =
'<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' +
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<span class="layer-name">' + escapeHtml(meta.label) + '</span>' +
(state.layers.on[key] ? blendSel : '') +
'<input type="range" min="0" max="100" step="1" value="' +
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
const eye = row.querySelector('.eye');
const range = row.querySelector('input[type="range"]');
const pct = row.querySelector('.layer-pct');
eye.addEventListener('click', () => {
state.layers.on[key] = !state.layers.on[key];
// Réactivation sur une opacité nulle : on remonte à 100 %
if (state.layers.on[key] && state.layers.opacity[key] === 0) state.layers.opacity[key] = 1;
saveLayers();
renderLayerPanel();
syncTileLayers();
});
range.addEventListener('input', () => {
const v = Number(range.value) / 100;
state.layers.opacity[key] = v;
pct.textContent = range.value + '%';
// Applique directement sans reconstruire le DOM
for (const entry of markers.values()) {
const img = entry.imgs[key];
if (img) img.style.opacity = String(v);
}
updateImages();
});
range.addEventListener('change', saveLayers);
const sel = row.querySelector('.layer-blend');
if (sel) sel.addEventListener('change', () => {
state.layers.blend[key] = sel.value;
saveLayers();
// Applique directement sans reconstruire le DOM
for (const entry of markers.values()) {
const img = entry.imgs[key];
if (img) img.style.mixBlendMode = sel.value;
}
});
row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); });
row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; });
row.addEventListener('dragover', e => e.preventDefault());
row.addEventListener('drop', e => {
e.preventDefault();
if (!dragKey || dragKey === key) return;
const ord = state.layers.order;
ord.splice(ord.indexOf(dragKey), 1);
ord.splice(ord.indexOf(key), 0, dragKey);
saveLayers();
renderLayerPanel();
syncTileLayers();
});
vizLayersEl.appendChild(row);
}
}
document.getElementById('osmToggle').addEventListener('click', () => {
state.layers.osm.on = !state.layers.osm.on;
saveLayers(); renderOsmRow();
});
document.getElementById('osmOpacity').addEventListener('input', e => {
state.layers.osm.opacity = Number(e.target.value) / 100;
osmLayer.setOpacity(state.layers.osm.opacity);
document.getElementById('osmPct').textContent = e.target.value + '%';
});
document.getElementById('osmOpacity').addEventListener('change', saveLayers);
// Clic sur le nom du fond = bascule mode sombre / clair
document.getElementById('osmRow').querySelector('.layer-name')
.addEventListener('click', () => {
state.layers.osm.dark = !state.layers.osm.dark;
saveLayers(); renderOsmRow();
});
document.getElementById('osmRow').querySelector('.layer-name').title =
'Cliquer : fond de carte clair / assombri';
// ============================================================
// Préconfigurations de couches : un preset définit les couches
// visibles, leurs niveaux de transparence (opacités des
// visualisations + fond de carte OSM) et l'ordre de pile
// (bas → haut). Un seul preset intégré sert de configuration de
// référence ; les presets personnels sont stockés sur le serveur
// (/api/presets, partagés entre tous les navigateurs connectés)
// et en écho dans le localStorage du navigateur (repli si l'interface
// est servie sans webapp).
// ============================================================
const PRESETS_LS = 'lidarPresets_v1';
const PRESETS_URL = '/api/presets';
// Écho local des presets : s'il n'y a pas de webapp (serveur statique),
// ils ne vivent que dans le localStorage du navigateur.
let customPresets = (() => {
try {
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
return Array.isArray(p) ? p : [];
} catch (e) { return []; }
})();
function persistCustomPresets() {
try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ }
}
// Les presets stockés sur le serveur sont partagés entre tous les
// navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux
// déjà en local (même id = la version serveur l'emporte). Webapp
// injoignable : la liste locale est conservée telle quelle.
function upsertCustomPreset(p) {
const i = customPresets.findIndex(x => x.id === p.id);
if (i === -1) customPresets.push(p); else customPresets[i] = p;
}
function removeCustomPreset(id) {
customPresets = customPresets.filter(p => p.id !== id);
}
async function syncCustomPresets() {
try {
const r = await fetch(PRESETS_URL, { cache: 'no-store' });
if (!r.ok) return;
const data = await r.json();
const server = Array.isArray(data.presets) ? data.presets : [];
const byId = new Map(customPresets.map(p => [p.id, p]));
for (const p of server) if (p && p.id) byId.set(p.id, p);
customPresets = [...byId.values()];
persistCustomPresets();
renderPresetSelect(presetSelectEl.value);
} catch (e) { /* pas de webapp : presets locaux uniquement */ }
}
// Les presets portent les niveaux de transparence : opacité de chaque
// visualisation + fond de carte OSM (visibilité, opacité, mode sombre).
const BUILTIN_PRESETS = [
{ label: 'Défaut', on: ['positive_openness', 'aspect'],
opacity: { aspect: 0.5, positive_openness: 1 },
osm: { on: true, opacity: 0.3 } },
];
// Niveaux de transparence d'un preset, lisibles dans la liste
// (opacité de chaque visualisation + fond de carte OSM).
function presetDetail(p) {
const parts = (p.on || []).filter(k => VIZ_META[k])
.map(k => (VIZ_META[k].label || k) + ' ' +
Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%' +
((p.blend && validBlend(p.blend[k]) && p.blend[k] !== 'normal')
? ' (' + blendLabel(p.blend[k]) + ')' : ''));
if (p.osm) parts.push('Fond ' + (p.osm.on
? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%'
: 'masqué'));
return parts.join(' · ');
}
function applyPreset(p) {
const want = (p.on || []).filter(k => VIZ_META[k]);
const L = state.layers;
const off = L.order.filter(k => !want.includes(k));
L.order = want.slice(); // ordre du preset (bas → haut)
L.order.push(...off); // les autres gardent leur ordre relatif
for (const k of L.order) {
const on = want.includes(k);
L.on[k] = on;
if (on) {
L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1;
if (p.blend && validBlend(p.blend[k])) L.blend[k] = p.blend[k];
}
}
// Niveaux de transparence du fond de carte (visibilité, opacité, sombre)
if (p.osm) {
L.osm.on = !!p.osm.on;
if (p.osm.opacity != null) L.osm.opacity = Number(p.osm.opacity);
if (p.osm.dark != null) L.osm.dark = !!p.osm.dark;
}
saveLayers();
renderLayerPanel();
renderOsmRow();
syncTileLayers();
}
const presetSelectEl = document.getElementById('presetSelect');
const presetSaveEl = document.getElementById('presetSave');
const presetDeleteEl = document.getElementById('presetDelete');
function renderPresetSelect(selectedValue) {
presetSelectEl.innerHTML = '';
const none = document.createElement('option');
none.value = '';
none.textContent = 'Préconfigurations…';
presetSelectEl.appendChild(none);
for (const p of BUILTIN_PRESETS) {
const o = document.createElement('option');
o.value = 'builtin:' + p.label;
o.textContent = p.label + ' — ' + presetDetail(p);
presetSelectEl.appendChild(o);
}
const customs = customPresets;
if (customs.length) {
const g = document.createElement('optgroup');
g.label = 'Mes presets';
for (const p of customs) {
const o = document.createElement('option');
o.value = 'custom:' + p.id;
o.textContent = p.label + ' — ' + presetDetail(p);
g.appendChild(o);
}
presetSelectEl.appendChild(g);
}
if (selectedValue) presetSelectEl.value = selectedValue;
presetDeleteEl.hidden = !presetSelectEl.value.startsWith('custom:');
}
presetSelectEl.addEventListener('change', () => {
const v = presetSelectEl.value;
if (!v) return;
let p = null;
if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7));
if (p) applyPreset(p);
});
presetSaveEl.addEventListener('click', () => {
const name = prompt('Nom du preset :');
if (!name || !name.trim()) return;
const enabled = state.layers.order.filter(k => state.layers.on[k]);
const osm = state.layers.osm;
const p = {
id: Date.now().toString(36),
label: name.trim(),
on: enabled,
opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])),
blend: Object.fromEntries(enabled.map(k => [k, state.layers.blend[k] || 'normal'])),
// Niveaux de transparence du fond de carte
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
};
upsertCustomPreset(p);
persistCustomPresets();
renderPresetSelect('custom:' + p.id);
// Persistance serveur (partagée entre navigateurs) ; en échec
// (pas de webapp), le preset reste dans le stockage local.
fetch(PRESETS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(p),
}).then(r => {
if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status);
}).catch(() => {});
});
presetDeleteEl.addEventListener('click', () => {
const v = presetSelectEl.value;
if (!v.startsWith('custom:')) return;
if (!confirm('Supprimer ce preset ?')) return;
const id = v.slice(7);
removeCustomPreset(id);
persistCustomPresets();
renderPresetSelect('');
fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' })
.catch(() => {});
});
renderPresetSelect();
syncCustomPresets();
const sidebar = document.getElementById('sidebar');
function setSidebarCollapsed(collapsed) {
sidebar.classList.toggle('collapsed', collapsed);
document.body.classList.toggle('sb-collapsed', collapsed);
try { localStorage.setItem('lidarSidebarCollapsed', collapsed ? '1' : ''); } catch (e) {}
}
document.getElementById('sidebarCollapse').addEventListener('click', () =>
setSidebarCollapsed(!sidebar.classList.contains('collapsed')));
try {
if (localStorage.getItem('lidarSidebarCollapsed') === '1') setSidebarCollapsed(true);
} catch (e) {}
const tabButtons = document.querySelectorAll('.tabbar .tab');
let activeTab = 'layers';
function switchTab(name) {
activeTab = name;
for (const b of tabButtons) {
b.classList.toggle('active', b.dataset.tab === name);
}
document.querySelectorAll('.tab-page').forEach(p => { p.hidden = p.id !== 'tab-' + name; });
// L'onglet Tuile est contextuel : sa sélection n'est pas mémorisée
if (name === 'layers' || name === 'gen') {
try { localStorage.setItem('lidarTab', name); } catch (e) {}
}
}
for (const b of tabButtons) b.addEventListener('click', () => switchTab(b.dataset.tab));
try {
const savedTab = localStorage.getItem('lidarTab');
if (savedTab === 'layers' || savedTab === 'gen') switchTab(savedTab);
} catch (e) {}
document.getElementById('btnMenu').addEventListener('click', () =>
document.body.classList.toggle('menu-open'));
// ============================================================
// Vue : zoom / pan / persistance et partage.
// Hash simple : #zoom,lat,lng. Hash partagé (bouton « Partager ») :
// #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark — L liste
// TOUTES les couches de bas en haut (opacité en %, visible 0/1), O
// décrit le fond OSM (affiché : opacité en % : mode sombre).
// ============================================================
function parseSharedHash() {
const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)(.*)$/.exec(location.hash || '');
if (!m || isNaN(parseFloat(m[1])) || isNaN(parseFloat(m[2])) || isNaN(parseFloat(m[3]))) return null;
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
layers: null, osm: null };
const seg = {};
for (const p of m[4].split('&')) {
const i = p.indexOf('=');
if (i > 0) seg[p.slice(0, i)] = p.slice(i + 1);
}
if (seg.L) {
const found = {}, order = [];
for (const part of seg.L.split(',')) {
const f = part.split(':');
if (f.length < 3 || !VIZ_META[f[0]]) continue;
const op = Number(f[1]);
if (isNaN(op)) continue;
found[f[0]] = { on: f[2] === '1', op: Math.min(100, Math.max(0, op)) };
order.push(f[0]);
}
if (order.length) {
for (const k of Object.keys(VIZ_META)) if (!order.includes(k)) order.push(k);
const on = {}, opacity = {};
for (const k of order) {
const e = found[k];
on[k] = e ? e.on : false;
opacity[k] = e ? e.op / 100 : 1;
}
out.layers = { order, on, opacity };
}
}
if (seg.O) {
const f = seg.O.split(':');
if (f.length === 3) {
const op = Number(f[1]);
if (!isNaN(op)) {
out.osm = { on: f[0] === '1', opacity: Math.min(100, Math.max(0, op)) / 100, dark: f[2] === '1' };
}
}
}
return out;
}
function readViewHash() { return parseSharedHash(); }
function saveViewHash() {
const c = map.getCenter();
history.replaceState(null, '', '#' +
map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6));
}
// Construit le lien partageable : vue courante + état complet des couches
// (pile, visibilité, opacités) + fond OSM.
function buildShareHash() {
const c = map.getCenter();
const L = state.layers.order.map(k =>
k + ':' + Math.round(state.layers.opacity[k] * 100) + ':' + (state.layers.on[k] ? '1' : '0')
).join(',');
const o = state.layers.osm;
return '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6) +
'&L=' + L +
'&O=' + (o.on ? '1' : '0') + ':' + Math.round(o.opacity * 100) + ':' + (o.dark ? '1' : '0');
}
// Copie du lien : l'API presse-papiers n'existe qu'en contexte sécurisé
// (https/localhost) — sur http://<raspberry-pi> 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 la couche est libérée juste après (mémoire vidéo).
let zoomGpuTimer = null;
// Animation de zoom des tuiles calée sur le fond de carte : les marqueurs
// Leaflet glissent (transition de position) sans changer de taille — les
// tuiles LiDAR « nageaient » donc sur le fond pendant l'animation puis
// sautaient à la fin. On applique au pane entier la mise à l'échelle que
// Leaflet applique aux couches de dalles : translate + scale autour de
// l'origine de la vue cible. La cible coïncide exactement avec la position
// que Marker._animateZoom donnerait à chaque icône (project(latlng, z1) −
// _getNewPixelOrigin(center, z1)) : le swap à la fin du zoom est sans saut.
map.on('zoomanim', (e) => {
const s = map.getZoomScale(e.zoom, map.getZoom());
const po0 = map.getPixelOrigin();
const po1 = map._getNewPixelOrigin(e.center, e.zoom);
lidarTilesPane.style.transform =
'translate3d(' + (s * po0.x - po1.x) + 'px,' + (s * po0.y - po1.y) + 'px,0) scale(' + s + ')';
});
map.on('zoomend', () => { lidarTilesPane.style.transform = ''; });
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', e => {
if (genSelMode) { pickGenSelCell(e.latlng); return; }
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 escapeHtml(s) {
// Noms de dalles/couches insérés en innerHTML : jamais de HTML brut,
// même si un fichier renommé dans input/ tente d'injecter du markup.
return String(s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
function humanSize(bytes) {
if (!Number.isFinite(bytes)) return '';
if (bytes < 1024) return bytes + ' o';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' Ko';
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
}
function layerPos(key) {
const i = state.layers.order.indexOf(key);
return i === -1 ? 999 : i;
}
function fmtShortDate(s) {
return (s && s.length >= 16)
? s.slice(8, 10) + '/' + s.slice(5, 7) + ' ' + s.slice(11, 16)
: (s || '');
}
function regenDisabledAttr() {
if (!apiAvailable) return ' disabled title="API indisponible — lancez ./run.sh --serve"';
if (!regenAllowed) return ' disabled title="Génération réservée au réseau local"';
return '';
}
function tileInfoContent(t) {
const meta = t.meta || {};
const res = String(t.resolution).replace('.', ',');
const el = document.createElement('div');
el.className = 'tile-info';
el.innerHTML =
'<div class="tile-info-head">' +
'<span class="tile-info-title">Tuile ' + t.col + ' · ' + t.row + '</span>' +
'<span class="tile-info-badge">' + res + ' m/px</span>' +
'<button class="tile-info-close" title="Fermer">×</button>' +
'</div>' +
'<div class="tile-info-dalle" title="' + escapeHtml(t.name) + '">' + escapeHtml(t.name) + '</div>' +
'<div class="tile-info-meta">' +
(t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km · ' + methodLabel(meta.method) +
(meta.generated ? ' · généré le ' + fmtShortDate(meta.generated) : '') +
'</div>' +
'<div class="tile-info-sub">Visualisations</div>' +
'<div class="vpills"></div>' +
'<div class="tile-info-actions">' +
'<button class="tile-info-regen"' + regenDisabledAttr() +
'>Régénérer cette tuile</button>' +
'</div>';
const pills = el.querySelector('.vpills');
Object.keys(t.viz)
.sort((a, b) => layerPos(a) - layerPos(b))
.forEach(k => {
const vm = meta.viz && meta.viz[k];
const label = VIZ_META[k] ? VIZ_META[k].label : k;
const tip = vm ? [label, vm.date, vm.size != null ? humanSize(vm.size) : '']
.filter(Boolean).join(' — ') : label;
const pill = document.createElement('span');
pill.className = 'vpill' + (state.layers.on[k] ? ' on' : '');
pill.title = tip;
pill.innerHTML =
'<button class="vpill-eye" data-viz="' + escapeHtml(k) + '" title="Afficher / masquer cette couche">' +
(state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<button class="vpill-name" data-viz="' + escapeHtml(k) + '" title="Afficher / masquer cette couche">' + escapeHtml(label) + '</button>' +
'<a class="vpill-dl" href="' + escapeHtml(t.viz[k].full) + '" download title="Télécharger l\'image">⤓</a>';
pills.appendChild(pill);
});
// Œil ou nom : bascule de la couche, reflet immédiat dans la puce
el.querySelectorAll('button[data-viz]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.viz;
if (!VIZ_META[k]) return;
state.layers.on[k] = !state.layers.on[k];
if (state.layers.on[k] && state.layers.opacity[k] === 0) state.layers.opacity[k] = 1;
saveLayers();
renderLayerPanel();
syncTileLayers();
const pill = btn.closest('.vpill');
if (pill) {
pill.classList.toggle('on', state.layers.on[k]);
const eye = pill.querySelector('.vpill-eye');
if (eye) eye.innerHTML = state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF;
}
});
});
const regen = el.querySelector('.tile-info-regen');
if (regen) regen.addEventListener('click', () => regenerateCurrentTile());
const closeBtn = el.querySelector('.tile-info-close');
if (closeBtn) closeBtn.addEventListener('click', hideTileInfo);
return el;
}
function showTileInfo(t) {
currentTile = t;
// Surlignage de la tuile sélectionnée
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (div) div.classList.toggle('selected', entry.t === t);
}
tileBody.replaceChildren(tileInfoContent(t));
switchTab('tile');
}
function hideTileInfo() {
currentTile = null;
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (div) div.classList.remove('selected');
}
const empty = document.createElement('div');
empty.className = 'tile-empty';
empty.textContent = 'Cliquez sur une tuile de la carte pour afficher ses informations de génération.';
tileBody.replaceChildren(empty);
if (activeTab === 'tile') switchTab('layers');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
hideTileInfo();
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 genEdgeBuffer = document.getElementById('genEdgeBuffer');
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 genQueueDrop = document.getElementById('genQueueDrop');
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 (clics + zones cumulés)
const genSelCorners = new Map(); // clé "col,row" → coins GPS (dalles sans données)
const genSelPolys = new Map(); // clé "col,row" → polygone Leaflet de surlignage
const renderFrames = new Map(); // clé "col,row" → cadre coloré de la dalle en cours de rendu
const GEN_MAX_CELLS = 400; // garde-fou serveur : ~400 km² max par demande
// 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.
// Rien de sélectionné → toutes les couches du sélecteur (= panneau affiché).
function selectedGenViz() {
const all = genViz ? Array.from(genViz.options).map(o => o.value) : [];
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
return sel.length ? sel : (all.length ? all : ['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();
pendingCompleteAll = false; // nouvelle zone : le scope global « Compléter » est levé
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; }
pendingCompleteAll = false; // zone dessinée : le scope global est levé
// La zone S'AJOUTE à la sélection en cours (zones successives, clics
// individuels) : les tuiles déjà retenues sont conservées, jamais perdues.
let added = 0;
for (const c of (d.cells || [])) {
const k = genSelKey(c.col, c.row);
if (!genSel.has(k)) { genSel.add(k); added++; }
if (c.corners) genSelCorners.set(k, c.corners);
}
refreshGenSelHighlights();
pendingTiles = genSelCells();
updateGenClassInfo();
if (!genSel.size) {
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 = 'Zone : ' + added + ' tuile(s) ajoutée(s)' +
(d.capped ? ' (zone tronquée à 400)' : '') +
' — ' + genSel.size + ' tuile(s) sélectionnée(s) au total' +
(genSel.size > GEN_MAX_CELLS
? ' (maximum ' + GEN_MAX_CELLS + ' par lancement : retirez des tuiles)' : '');
genGo.style.display = genSel.size > GEN_MAX_CELLS ? 'none' : '';
}).catch(() => { showGenBar('API indisponible'); });
});
// « Sélectionner des tuiles » : sélection cumulative, tuile par tuile — le
// clic sur la carte ajoute ou retire la dalle de 1 km sous le curseur, avec
// ou sans données existantes (téléchargement + génération à la validation ;
// les sous-tuiles 0,2 m comptent pour une seule dalle). Les zones dessinées
// s'ajoutent à la même sélection. La barre de confirmation permet de choisir
// les couches et les options avant de lancer (--force implicite sur une
// sélection clic par clic : retraitement ciblé des tuiles choisies).
function genSelKey(col, row) { return col + ',' + row; }
function refreshGenSelHighlights() {
const markerCells = new Set();
for (const entry of markers.values()) {
const k = genSelKey(entry.t.col, entry.t.row);
markerCells.add(k);
const div = entryDiv(entry);
if (div) div.classList.toggle('genSel', genSel.has(k));
}
// Dalles sans données (aucun marqueur) : surlignage en polygone
for (const [k, poly] of genSelPolys) {
if (!genSel.has(k) || markerCells.has(k)) { poly.remove(); genSelPolys.delete(k); }
}
for (const k of genSel) {
if (markerCells.has(k) || genSelPolys.has(k)) continue;
const corners = genSelCorners.get(k);
if (!corners) continue;
genSelPolys.set(k, L.polygon(corners,
{ color: '#5aa9e6', weight: 1.5, fillOpacity: 0.15, pane: 'genDraw' }).addTo(map));
}
}
function genSelCells() {
return [...genSel].map(k => k.split(',').map(Number));
}
// Cadres colorés sur la carte autour des dalles du run en cours : orange
// pulsant pour la dalle en cours de rendu, bleu pointillé pour celles qui
// attendent, rouge pour les échecs. Les coins viennent de /api/status
// (attachés aux dalles non terminées, y compris sans données existantes) ;
// un sondage sans coins n'affiche rien — la file de l'onglet Génération
// reste la référence.
const RENDER_FRAME_STYLES = {
running: { color: '#ffb454', weight: 3, fillOpacity: 0.08,
className: 'render-frame render-running' },
pending: { color: '#5aa9e6', weight: 1.5, dashArray: '6 5', fill: false,
className: 'render-frame render-pending' },
failed: { color: '#e94560', weight: 2, dashArray: '2 4', fillOpacity: 0.08,
className: 'render-frame render-failed' }
};
function updateRenderFrames(tiles) {
const wanted = new Map();
for (const t of (tiles || [])) {
if (t.state === 'done' || !t.corners || t.col == null || t.row == null) continue;
if (!RENDER_FRAME_STYLES[t.state]) continue;
wanted.set(genSelKey(t.col, t.row), t);
}
for (const [k, frame] of renderFrames) {
if (wanted.has(k) && wanted.get(k).state === frame.state) continue;
frame.rect.remove();
renderFrames.delete(k);
}
for (const [k, t] of wanted) {
if (renderFrames.has(k)) continue;
const rect = L.polygon(t.corners, Object.assign(
{ pane: 'genDraw', interactive: false }, RENDER_FRAME_STYLES[t.state])).addTo(map);
renderFrames.set(k, { rect: rect, state: t.state });
}
}
function clearRenderFrames() {
renderFrames.forEach(f => f.rect.remove());
renderFrames.clear();
}
function updateGenSelBar() {
showGenBar(!genSel.size
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile'
: genSel.size > GEN_MAX_CELLS
? genSel.size + ' tuile(s) sélectionnée(s) — maximum ' + GEN_MAX_CELLS + ' par lancement'
: genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer');
genGo.style.display = genSel.size && genSel.size <= GEN_MAX_CELLS ? '' : 'none';
}
function toggleGenSelCell(col, row, corners) {
const key = genSelKey(col, row);
if (genSel.has(key)) genSel.delete(key);
else {
genSel.add(key);
if (corners) genSelCorners.set(key, corners);
}
refreshGenSelHighlights();
pendingTiles = genSelCells();
pendingRegen = true;
pendingCompleteAll = false;
updateGenSelBar();
updateGenClassInfo();
}
// Clic sur la carte hors dalle affichée : le serveur convertit le point
// WGS84 en dalle L93 de 1 km — permet de sélectionner des tuiles qui
// n'existent pas encore (aucun marqueur, aucune donnée).
function pickGenSelCell(latlng) {
fetch('/api/cell?lat=' + latlng.lat + '&lng=' + latlng.lng)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d && d.col !== null && d.col !== undefined && d.row !== undefined)
toggleGenSelCell(d.col, d.row, d.corners);
}).catch(() => { /* hors ligne : le clic suivant réessaiera */ });
}
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 sur la carte'
: '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 la carte = ajouter / retirer une tuile<br>La sélection se cumule avec les zones dessinées — Échap pour terminer'
: 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();
genSelCorners.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',
edge_buffer: genEdgeBuffer ? genEdgeBuffer.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(d => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
pendingRegen = false;
if (fromSel) clearGenSel();
const label = nTiles + ' tuile(s) — ' + verb;
// Un run est déjà en cours : la demande est partie en file d'attente
// côté serveur et démarrera à sa fin, sans couper le travail en place.
if (d && d['demarré'] === false && d.en_file)
return startGenTask(label, { qid: d.qid, position: d.en_file });
return startGenTask(label);
})
.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<br>É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' : '')
+ ' <span class="sub">' + sub + '</span>';
}
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é */ }
}
// Runs dont l'utilisateur a retiré la tâche « finie » de la liste : on les
// mémorise (par horodatage serveur `started`) pour que le rechargement de la
// page — qui réadopte la « dernière génération » depuis .generation.job.json
// — ne les re-crée pas à chaque rafraîchissement.
const GEN_DKEY = 'lidarGenDismissed';
let dismissedRuns = new Set();
try { dismissedRuns = new Set(JSON.parse(sessionStorage.getItem(GEN_DKEY) || '[]')); } catch (e) { dismissedRuns = new Set(); }
function saveDismissed() {
try { sessionStorage.setItem(GEN_DKEY, JSON.stringify([...dismissedRuns])); } 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 === 'queued') {
state.textContent = t.position
? 'En file — position ' + t.position
: 'En file d\'attente';
} else 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;
const queued = genTasks.filter(t => t.state === 'queued').length;
genBadge.hidden = running === 0;
genBadge.textContent = running > 0 ? String(running) : '';
if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; }
const active = running + queued;
genQueueCount.textContent = genTasks.length
? (active ? active + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
: '';
genQueue.hidden = genTasks.length === 0;
}
function startGenTask(labelText, opts) {
const now = new Date();
const qid = opts ? opts.qid : undefined;
genTasks.push({
id: Date.now(), label: labelText,
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
// Demande partie en file d'attente serveur : suivie jusqu'à son tour
state: qid !== undefined ? 'queued' : 'running',
qid: qid, position: opts ? opts.position : undefined,
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;
// Sans identité de run capturée (tâche lancée immédiatement, jamais en
// file) : on mémorise l'horodatage du run pour pouvoir la retirer sans
// qu'elle soit réadoptée au rechargement de la page.
if (t.started === undefined) t.started = d.started;
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'<img> 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();
// Une dalle qui vient d'apparaître prend le relais du polygone de
// sélection (surlignage du marqueur désormais possible)
refreshGenSelHighlights();
}
}
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;
// Demandes en file côté serveur : bouton de retrait
if (genQueueDrop) genQueueDrop.hidden = !(d.queue && d.queue.length);
// Tâches en file : démarrent quand le serveur attaque leur run (qid),
// « Interrompu » si la demande est retirée sans avoir tourné.
const serverQids = new Set((d.queue || []).map(q => q.id));
for (const t of genTasks) {
if (t.state !== 'queued') continue;
const gone = !serverQids.has(t.qid);
if (d.running && d.qid === t.qid) {
// Le run de cette demande a démarré : les tâches « en cours » plus
// anciennes appartenaient au run précédent, forcément terminé (le
// serveur n'exécute qu'un run à la fois).
for (const o of genTasks) {
if (o !== t && o.state === 'running') { o.state = 'done'; o.returncode = 0; }
}
t.state = 'running'; t.started = d.started; t.position = undefined;
} else if (d.running && d.qid == null && gone) {
t.state = 'running'; t.started = d.started; t.position = undefined;
} else if (!d.running && gone) {
t.state = 'stopped';
}
}
const running = genTasks.filter(t => t.state === 'running');
const queued = genTasks.filter(t => t.state === 'queued');
if (!running.length && !queued.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) {
clearRenderFrames();
finishRunningTasks(d);
if (genTasks.some(t => t.state === 'queued')) {
// Des demandes attendent encore leur tour : poursuivre la veille
// (le rechargement de fin de run attendra la fin de la file).
saveGenTasks(); renderGenQueue();
return;
}
clearInterval(genPollTimer); genPollTimer = null;
// 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();
updateRenderFrames(d.tiles);
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. Le rechargement attend la fin de CE rebuild (done >= started,
// horodatage serveur retourné au POST) : sans cela, un sondage trop tôt
// lirait le done du rebuild précédent et rechargerait une carte périmée.
// Rechargement de secours après 30 min.
function syncThenReload() {
fetch('/api/sync', { method: 'POST' })
.then(r => r.ok ? r.json() : null)
.then(d => {
const started = (d && d.started) || 0;
const t0 = Date.now();
const check = setInterval(() => {
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
const finished = s && !s.running
&& (!started || (s.done || 0) >= started);
if (finished || (s && s.error) || Date.now() - t0 > 30 * 60 * 1000) {
clearInterval(check);
location.reload();
}
}).catch(() => { clearInterval(check); location.reload(); });
}, 2000);
}).catch(() => location.reload());
}
genQueueClear.addEventListener('click', () => {
// Les tâches retirées (finies, interrompues, échecs) sont mémorisées : sans
// cela le rechargement réadopterait la « dernière génération » du serveur
// (.generation.job.json) et les ferait réapparaître.
for (const t of genTasks) {
if (t.state === 'running' || t.state === 'queued') continue;
if (t.started !== undefined) dismissedRuns.add(t.started);
}
genTasks = genTasks.filter(t => t.state === 'running' || t.state === 'queued');
saveGenTasks(); saveDismissed(); 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);
});
});
// Retrait des demandes en file d'attente côté serveur : le run en cours
// continue, les demandes non démarrées sont abandonnées.
if (genQueueDrop) genQueueDrop.addEventListener('click', () => {
genQueueDrop.disabled = true;
fetch('/api/queue/clear', { method: 'POST' })
.then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => {
genQueueDrop.disabled = false;
genQueueDrop.hidden = true;
if (!genPollTimer) pollGenStatus(); // marquer les tâches « Interrompu »
})
.catch(msg => {
genQueueDrop.disabled = false;
showGenBar('Erreur file d\'attente : ' + 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' || t.state === 'queued')) pollGenStatus();
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
if (!s || genTasks.some(t => t.started === s.started)) return;
// Déjà retirée manuellement de la liste : ne pas réadopter ce run.
if (s.started != null && dismissedRuns.has(s.started)) return;
// Déjà suivie par une tâche (en cours ou en file côté serveur) : pas
// d'adoption en doublon d'un run lancé depuis cet onglet.
const trackedQids = new Set(genTasks.filter(t => t.qid !== undefined).map(t => t.qid));
if (s.qid != null && trackedQids.has(s.qid)) return;
if ((s.queue || []).some(q => trackedQids.has(q.id))) 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();
updateRenderFrames(s.tiles);
} 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
"""