4058 lines
170 KiB
Python
4058 lines
170 KiB
Python
"""Carte continue interactive des tuiles LiDAR traitées.
|
||
|
||
Génère l'interface web de consultation (servie par webapp.py via --serve) :
|
||
- output/index.html : coquille HTML minimaliste embarquant les données
|
||
- output/assets/app.css : styles (panneaux flottants type SIG)
|
||
- output/assets/app.js : logique carte (Leaflet), couches, infos tuiles
|
||
|
||
Fonctionnalités :
|
||
- Carte continue zoom/pan (Leaflet), tuiles rotées selon la projection
|
||
Lambert 93, vignettes ↔ images pleine résolution selon le zoom.
|
||
- Panneau de couches superposables avec opacité individuelle et
|
||
réordonnancement par glisser-déposer (persisté en localStorage).
|
||
- Panneau d'infos par tuile : résolution, méthode de classification du sol
|
||
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
|
||
- Génération 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}
|
||
|
||
# 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)")
|
||
try:
|
||
img = PILImage.open(str(src))
|
||
img.load()
|
||
except Exception as e:
|
||
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
|
||
_fallback_full_dalle(entries, viz_key, info)
|
||
continue
|
||
if img.mode not in ('RGB', 'L'):
|
||
img = img.convert('RGB')
|
||
W, H = img.size
|
||
cut_failed = False
|
||
for (i, j), stem in stems.items():
|
||
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
|
||
left, right = round(W * i / k), round(W * (i + 1) / k)
|
||
top = round(H * (1 - (j + 1) / k))
|
||
bottom = round(H * (1 - j / k))
|
||
quad = img.crop((left, top, right, bottom))
|
||
try:
|
||
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
|
||
format='AVIF', quality=avif_quality,
|
||
speed=_SUBTILE_AVIF_SPEED)
|
||
except Exception as e:
|
||
logger.warning(f"Encodage AVIF impossible ({stem}), "
|
||
f"repli dalle entière pour {viz_key} : {e}")
|
||
cut_failed = True
|
||
break
|
||
# Supprime 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),
|
||
'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),
|
||
'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="Ramène le MNT au retour le plus bas de chaque cellule (sol nu)">
|
||
<input type="checkbox" id="genBareEarth"> Sol nu
|
||
</label>
|
||
<div class="gen-run">
|
||
<button id="genGo" class="primary">Lancer</button>
|
||
<button id="genCancel">Annuler</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File de génération : dans l'onglet, plus en surimpression -->
|
||
<div id="genQueue" hidden>
|
||
<div class="queue-head">
|
||
<span class="panel-title">File de génération</span>
|
||
<span id="genQueueCount"></span>
|
||
<button id="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;
|
||
}
|
||
#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;
|
||
/* 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; }
|
||
|
||
/* 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.
|
||
const LS_KEY = 'lidarLayers_v1';
|
||
|
||
function defaultLayers() {
|
||
const order = Object.keys(VIZ_META);
|
||
// Couches activées par défaut : seules celles-ci sont allumées, toutes les
|
||
// autres sont désactivées (mais restent accessibles via le panneau). Repli
|
||
// sur la couche « principale » (default_viz) si la liste n'est pas fournie
|
||
// (index ancien) ; sinon la première couche disponible.
|
||
const list = (Array.isArray(STATS.default_layers) && STATS.default_layers.length)
|
||
? STATS.default_layers
|
||
: ((STATS.default_viz && VIZ_META[STATS.default_viz])
|
||
? [STATS.default_viz] : [order[0]]);
|
||
const 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 : {};
|
||
const onSet = new Set(defOrder);
|
||
const on = {}, opacity = {};
|
||
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));
|
||
}
|
||
return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } };
|
||
}
|
||
|
||
function loadLayers() {
|
||
const def = defaultLayers();
|
||
let st = def;
|
||
try {
|
||
const raw = localStorage.getItem(LS_KEY);
|
||
if (raw) {
|
||
const saved = JSON.parse(raw);
|
||
const order = (Array.isArray(saved.order) ? saved.order : [])
|
||
.filter(k => VIZ_META[k]);
|
||
for (const k of Object.keys(VIZ_META)) {
|
||
if (!order.includes(k)) order.push(k);
|
||
}
|
||
const on = {}, opacity = {};
|
||
for (const k of order) {
|
||
const op = saved.opacity ? saved.opacity[k] : undefined;
|
||
on[k] = saved.on && saved.on[k] === true;
|
||
opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op)))
|
||
? 1 : Math.min(1, Math.max(0, Number(op)));
|
||
}
|
||
st = { order, on, opacity, osm: Object.assign({}, def.osm, saved.osm || {}) };
|
||
}
|
||
} catch (e) { st = def; }
|
||
// Lien partagé (vue + couches + transparences) : il prime sur l'état local.
|
||
if (sharedHash) {
|
||
if (sharedHash.layers) st = sharedHash.layers;
|
||
if (sharedHash.osm) st = Object.assign({}, st, { osm: sharedHash.osm });
|
||
}
|
||
return st;
|
||
}
|
||
|
||
// Lien partagé analysé une seule fois au chargement :
|
||
// #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark (format détaillé
|
||
// dans parseSharedHash). Les couches partagent l'ordre de pile (bas → haut)
|
||
// et l'opacité en pourcentage, indépendamment du localStorage local.
|
||
const sharedHash = parseSharedHash();
|
||
|
||
const state = { layers: loadLayers() };
|
||
|
||
function saveLayers() {
|
||
try { localStorage.setItem(LS_KEY, JSON.stringify(state.layers)); } catch (e) { /* privé */ }
|
||
}
|
||
|
||
function activeLayers() {
|
||
return state.layers.order.filter(k => state.layers.on[k] && VIZ_META[k]);
|
||
}
|
||
|
||
// ============================================================
|
||
// Marqueurs : une div par tuile, une <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]);
|
||
});
|
||
}
|
||
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;
|
||
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>' +
|
||
'<input type="range" min="0" max="100" step="1" value="' +
|
||
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
|
||
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
|
||
|
||
const eye = row.querySelector('.eye');
|
||
const range = row.querySelector('input[type="range"]');
|
||
const pct = row.querySelector('.layer-pct');
|
||
|
||
eye.addEventListener('click', () => {
|
||
state.layers.on[key] = !state.layers.on[key];
|
||
// Réactivation sur une opacité nulle : on remonte à 100 %
|
||
if (state.layers.on[key] && state.layers.opacity[key] === 0) state.layers.opacity[key] = 1;
|
||
saveLayers();
|
||
renderLayerPanel();
|
||
syncTileLayers();
|
||
});
|
||
range.addEventListener('input', () => {
|
||
const v = Number(range.value) / 100;
|
||
state.layers.opacity[key] = v;
|
||
pct.textContent = range.value + '%';
|
||
// Applique directement sans reconstruire le DOM
|
||
for (const entry of markers.values()) {
|
||
const img = entry.imgs[key];
|
||
if (img) img.style.opacity = String(v);
|
||
}
|
||
updateImages();
|
||
});
|
||
range.addEventListener('change', saveLayers);
|
||
|
||
row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); });
|
||
row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; });
|
||
row.addEventListener('dragover', e => e.preventDefault());
|
||
row.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
if (!dragKey || dragKey === key) return;
|
||
const ord = state.layers.order;
|
||
ord.splice(ord.indexOf(dragKey), 1);
|
||
ord.splice(ord.indexOf(key), 0, dragKey);
|
||
saveLayers();
|
||
renderLayerPanel();
|
||
syncTileLayers();
|
||
});
|
||
vizLayersEl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
document.getElementById('osmToggle').addEventListener('click', () => {
|
||
state.layers.osm.on = !state.layers.osm.on;
|
||
saveLayers(); renderOsmRow();
|
||
});
|
||
document.getElementById('osmOpacity').addEventListener('input', e => {
|
||
state.layers.osm.opacity = Number(e.target.value) / 100;
|
||
osmLayer.setOpacity(state.layers.osm.opacity);
|
||
document.getElementById('osmPct').textContent = e.target.value + '%';
|
||
});
|
||
document.getElementById('osmOpacity').addEventListener('change', saveLayers);
|
||
// Clic sur le nom du fond = bascule mode sombre / clair
|
||
document.getElementById('osmRow').querySelector('.layer-name')
|
||
.addEventListener('click', () => {
|
||
state.layers.osm.dark = !state.layers.osm.dark;
|
||
saveLayers(); renderOsmRow();
|
||
});
|
||
document.getElementById('osmRow').querySelector('.layer-name').title =
|
||
'Cliquer : fond de carte clair / assombri';
|
||
|
||
// ============================================================
|
||
// Préconfigurations de couches : un preset définit les couches
|
||
// visibles, leurs niveaux de transparence (opacités des
|
||
// visualisations + fond de carte OSM) et l'ordre de pile
|
||
// (bas → haut). Les presets intégrés couvrent les couches du
|
||
// panneau ; les presets personnels sont stockés sur le serveur
|
||
// (/api/presets, partagés entre tous les navigateurs connectés)
|
||
// et en écho dans le localStorage du navigateur (repli si l'interface
|
||
// est servie sans webapp).
|
||
// ============================================================
|
||
const PRESETS_LS = 'lidarPresets_v1';
|
||
const PRESETS_URL = '/api/presets';
|
||
// Écho local des presets : s'il n'y a pas de webapp (serveur statique),
|
||
// ils ne vivent que dans le localStorage du navigateur.
|
||
let customPresets = (() => {
|
||
try {
|
||
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
|
||
return Array.isArray(p) ? p : [];
|
||
} catch (e) { return []; }
|
||
})();
|
||
function persistCustomPresets() {
|
||
try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ }
|
||
}
|
||
|
||
// Les presets stockés sur le serveur sont partagés entre tous les
|
||
// navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux
|
||
// déjà en local (même id = la version serveur l'emporte). Webapp
|
||
// injoignable : la liste locale est conservée telle quelle.
|
||
function upsertCustomPreset(p) {
|
||
const i = customPresets.findIndex(x => x.id === p.id);
|
||
if (i === -1) customPresets.push(p); else customPresets[i] = p;
|
||
}
|
||
function removeCustomPreset(id) {
|
||
customPresets = customPresets.filter(p => p.id !== id);
|
||
}
|
||
async function syncCustomPresets() {
|
||
try {
|
||
const r = await fetch(PRESETS_URL, { cache: 'no-store' });
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
const server = Array.isArray(data.presets) ? data.presets : [];
|
||
const byId = new Map(customPresets.map(p => [p.id, p]));
|
||
for (const p of server) if (p && p.id) byId.set(p.id, p);
|
||
customPresets = [...byId.values()];
|
||
persistCustomPresets();
|
||
renderPresetSelect(presetSelectEl.value);
|
||
} catch (e) { /* pas de webapp : presets locaux uniquement */ }
|
||
}
|
||
// Les presets portent les niveaux de transparence : opacité de chaque
|
||
// visualisation + fond de carte OSM (visibilité, opacité, mode sombre).
|
||
const BUILTIN_PRESETS = [
|
||
{ label: 'Relief', on: ['aspect', 'positive_openness'],
|
||
opacity: { aspect: 1, positive_openness: 0.65 },
|
||
osm: { on: false } },
|
||
{ label: 'Pentes', on: ['aspect', 'slope'],
|
||
opacity: { aspect: 0.5, slope: 1 },
|
||
osm: { on: true, opacity: 0.3 } },
|
||
{ label: 'Cavités', on: ['slope', 'positive_openness'],
|
||
opacity: { slope: 0.5, positive_openness: 1 },
|
||
osm: { on: false } },
|
||
{ label: 'Tout', on: ['slope', 'aspect', 'positive_openness'],
|
||
opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55 },
|
||
osm: { on: true, opacity: 0.85, dark: true } },
|
||
];
|
||
|
||
// Niveaux de transparence d'un preset, lisibles dans la liste
|
||
// (opacité de chaque visualisation + fond de carte OSM).
|
||
function presetDetail(p) {
|
||
const parts = (p.on || []).filter(k => VIZ_META[k])
|
||
.map(k => (VIZ_META[k].label || k) + ' ' +
|
||
Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%');
|
||
if (p.osm) parts.push('Fond ' + (p.osm.on
|
||
? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%'
|
||
: 'masqué'));
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function applyPreset(p) {
|
||
const want = (p.on || []).filter(k => VIZ_META[k]);
|
||
const L = state.layers;
|
||
const off = L.order.filter(k => !want.includes(k));
|
||
L.order = want.slice(); // ordre du preset (bas → haut)
|
||
L.order.push(...off); // les autres gardent leur ordre relatif
|
||
for (const k of L.order) {
|
||
const on = want.includes(k);
|
||
L.on[k] = on;
|
||
if (on) L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1;
|
||
}
|
||
// Niveaux de transparence du fond de carte (visibilité, opacité, sombre)
|
||
if (p.osm) {
|
||
L.osm.on = !!p.osm.on;
|
||
if (p.osm.opacity != null) L.osm.opacity = Number(p.osm.opacity);
|
||
if (p.osm.dark != null) L.osm.dark = !!p.osm.dark;
|
||
}
|
||
saveLayers();
|
||
renderLayerPanel();
|
||
renderOsmRow();
|
||
syncTileLayers();
|
||
}
|
||
|
||
const presetSelectEl = document.getElementById('presetSelect');
|
||
const presetSaveEl = document.getElementById('presetSave');
|
||
const presetDeleteEl = document.getElementById('presetDelete');
|
||
|
||
function renderPresetSelect(selectedValue) {
|
||
presetSelectEl.innerHTML = '';
|
||
const none = document.createElement('option');
|
||
none.value = '';
|
||
none.textContent = 'Préconfigurations…';
|
||
presetSelectEl.appendChild(none);
|
||
for (const p of BUILTIN_PRESETS) {
|
||
const o = document.createElement('option');
|
||
o.value = 'builtin:' + p.label;
|
||
o.textContent = p.label + ' — ' + presetDetail(p);
|
||
presetSelectEl.appendChild(o);
|
||
}
|
||
const customs = customPresets;
|
||
if (customs.length) {
|
||
const g = document.createElement('optgroup');
|
||
g.label = 'Mes presets';
|
||
for (const p of customs) {
|
||
const o = document.createElement('option');
|
||
o.value = 'custom:' + p.id;
|
||
o.textContent = p.label + ' — ' + presetDetail(p);
|
||
g.appendChild(o);
|
||
}
|
||
presetSelectEl.appendChild(g);
|
||
}
|
||
if (selectedValue) presetSelectEl.value = selectedValue;
|
||
presetDeleteEl.hidden = !presetSelectEl.value.startsWith('custom:');
|
||
}
|
||
|
||
presetSelectEl.addEventListener('change', () => {
|
||
const v = presetSelectEl.value;
|
||
if (!v) return;
|
||
let p = null;
|
||
if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
|
||
else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7));
|
||
if (p) applyPreset(p);
|
||
});
|
||
|
||
presetSaveEl.addEventListener('click', () => {
|
||
const name = prompt('Nom du preset :');
|
||
if (!name || !name.trim()) return;
|
||
const enabled = state.layers.order.filter(k => state.layers.on[k]);
|
||
const osm = state.layers.osm;
|
||
const p = {
|
||
id: Date.now().toString(36),
|
||
label: name.trim(),
|
||
on: enabled,
|
||
opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])),
|
||
// Niveaux de transparence du fond de carte
|
||
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
|
||
};
|
||
upsertCustomPreset(p);
|
||
persistCustomPresets();
|
||
renderPresetSelect('custom:' + p.id);
|
||
// Persistance serveur (partagée entre navigateurs) ; en échec
|
||
// (pas de webapp), le preset reste dans le stockage local.
|
||
fetch(PRESETS_URL, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(p),
|
||
}).then(r => {
|
||
if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status);
|
||
}).catch(() => {});
|
||
});
|
||
|
||
presetDeleteEl.addEventListener('click', () => {
|
||
const v = presetSelectEl.value;
|
||
if (!v.startsWith('custom:')) return;
|
||
if (!confirm('Supprimer ce preset ?')) return;
|
||
const id = v.slice(7);
|
||
removeCustomPreset(id);
|
||
persistCustomPresets();
|
||
renderPresetSelect('');
|
||
fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' })
|
||
.catch(() => {});
|
||
});
|
||
|
||
renderPresetSelect();
|
||
syncCustomPresets();
|
||
const sidebar = document.getElementById('sidebar');
|
||
function setSidebarCollapsed(collapsed) {
|
||
sidebar.classList.toggle('collapsed', collapsed);
|
||
document.body.classList.toggle('sb-collapsed', collapsed);
|
||
try { localStorage.setItem('lidarSidebarCollapsed', collapsed ? '1' : ''); } catch (e) {}
|
||
}
|
||
document.getElementById('sidebarCollapse').addEventListener('click', () =>
|
||
setSidebarCollapsed(!sidebar.classList.contains('collapsed')));
|
||
try {
|
||
if (localStorage.getItem('lidarSidebarCollapsed') === '1') setSidebarCollapsed(true);
|
||
} catch (e) {}
|
||
|
||
const tabButtons = document.querySelectorAll('.tabbar .tab');
|
||
let activeTab = 'layers';
|
||
function switchTab(name) {
|
||
activeTab = name;
|
||
for (const b of tabButtons) {
|
||
b.classList.toggle('active', b.dataset.tab === name);
|
||
}
|
||
document.querySelectorAll('.tab-page').forEach(p => { p.hidden = p.id !== 'tab-' + name; });
|
||
// L'onglet Tuile est contextuel : sa sélection n'est pas mémorisée
|
||
if (name === 'layers' || name === 'gen') {
|
||
try { localStorage.setItem('lidarTab', name); } catch (e) {}
|
||
}
|
||
}
|
||
for (const b of tabButtons) b.addEventListener('click', () => switchTab(b.dataset.tab));
|
||
try {
|
||
const savedTab = localStorage.getItem('lidarTab');
|
||
if (savedTab === 'layers' || savedTab === 'gen') switchTab(savedTab);
|
||
} catch (e) {}
|
||
|
||
document.getElementById('btnMenu').addEventListener('click', () =>
|
||
document.body.classList.toggle('menu-open'));
|
||
|
||
// ============================================================
|
||
// Vue : zoom / pan / persistance et partage.
|
||
// Hash simple : #zoom,lat,lng. Hash partagé (bouton « Partager ») :
|
||
// #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark — L liste
|
||
// TOUTES les couches de bas en haut (opacité en %, visible 0/1), O
|
||
// décrit le fond OSM (affiché : opacité en % : mode sombre).
|
||
// ============================================================
|
||
function parseSharedHash() {
|
||
const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)(.*)$/.exec(location.hash || '');
|
||
if (!m || isNaN(parseFloat(m[1])) || isNaN(parseFloat(m[2])) || isNaN(parseFloat(m[3]))) return null;
|
||
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
|
||
layers: null, osm: null };
|
||
const seg = {};
|
||
for (const p of m[4].split('&')) {
|
||
const i = p.indexOf('=');
|
||
if (i > 0) seg[p.slice(0, i)] = p.slice(i + 1);
|
||
}
|
||
if (seg.L) {
|
||
const found = {}, order = [];
|
||
for (const part of seg.L.split(',')) {
|
||
const f = part.split(':');
|
||
if (f.length < 3 || !VIZ_META[f[0]]) continue;
|
||
const op = Number(f[1]);
|
||
if (isNaN(op)) continue;
|
||
found[f[0]] = { on: f[2] === '1', op: Math.min(100, Math.max(0, op)) };
|
||
order.push(f[0]);
|
||
}
|
||
if (order.length) {
|
||
for (const k of Object.keys(VIZ_META)) if (!order.includes(k)) order.push(k);
|
||
const on = {}, opacity = {};
|
||
for (const k of order) {
|
||
const e = found[k];
|
||
on[k] = e ? e.on : false;
|
||
opacity[k] = e ? e.op / 100 : 1;
|
||
}
|
||
out.layers = { order, on, opacity };
|
||
}
|
||
}
|
||
if (seg.O) {
|
||
const f = seg.O.split(':');
|
||
if (f.length === 3) {
|
||
const op = Number(f[1]);
|
||
if (!isNaN(op)) {
|
||
out.osm = { on: f[0] === '1', opacity: Math.min(100, Math.max(0, op)) / 100, dark: f[2] === '1' };
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function readViewHash() { return parseSharedHash(); }
|
||
function saveViewHash() {
|
||
const c = map.getCenter();
|
||
history.replaceState(null, '', '#' +
|
||
map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6));
|
||
}
|
||
// Construit le lien partageable : vue courante + état complet des couches
|
||
// (pile, visibilité, opacités) + fond OSM.
|
||
function buildShareHash() {
|
||
const c = map.getCenter();
|
||
const L = state.layers.order.map(k =>
|
||
k + ':' + Math.round(state.layers.opacity[k] * 100) + ':' + (state.layers.on[k] ? '1' : '0')
|
||
).join(',');
|
||
const o = state.layers.osm;
|
||
return '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6) +
|
||
'&L=' + L +
|
||
'&O=' + (o.on ? '1' : '0') + ':' + Math.round(o.opacity * 100) + ':' + (o.dark ? '1' : '0');
|
||
}
|
||
// Copie du lien : l'API presse-papiers n'existe qu'en contexte sécurisé
|
||
// (https/localhost) — sur http://<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 =>
|
||
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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 genBareEarth = document.getElementById('genBareEarth');
|
||
const genQueue = document.getElementById('genQueue');
|
||
const genBadge = document.getElementById('genBadge');
|
||
const genStop = document.getElementById('genStop');
|
||
const genQueueBody = document.getElementById('genQueueBody');
|
||
const genQueueClear = document.getElementById('genQueueClear');
|
||
const 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 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));
|
||
}
|
||
|
||
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',
|
||
bare_earth: genBareEarth ? genBareEarth.checked : false,
|
||
reclassify: genReclass ? genReclass.checked : false })
|
||
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
|
||
.then(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) {
|
||
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();
|
||
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();
|
||
} 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
|
||
"""
|