Tuiles visibles en direct après régénération et déploiement webapp distante
- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et force le rechargement navigateur (cache heuristique contourné), y compris en plein run via /api/tiles ; veille permanente 15 s sur la carte - simulation locale du mode deux machines (docker-compose.local-2m.yml) : worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/ - override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output, rsync vers le worker, labels Traefik (proxy/websecure/myresolver) - purge 0,5 m : worker/process et politique générale passés à 0,2 m seul - intègre le travail parallèle non commité : export mosaïque multi-dalles (export.py + /api/export), sous-tuilage intégral des couches, légendes VIZ_LEGENDS, docs et tests associés (213 tests verts)
This commit is contained in:
@ -14,6 +14,9 @@ Fonctionnalités :
|
||||
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
|
||||
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
|
||||
dessin d'un rectangle → téléchargement IGN + traitement.
|
||||
- Onglet Export : sélection de dalles sur la carte → mosaïque jointive
|
||||
(image ou PDF multi-couches) téléchargeable pour consultation sur
|
||||
téléphone (/api/export de la webapp, module export.py).
|
||||
|
||||
Intégration:
|
||||
- Appelé automatiquement à la fin de process_all() dans pipeline.py
|
||||
@ -50,13 +53,158 @@ VIZ_LABELS = {
|
||||
'topo': 'Carte topographique IGN',
|
||||
}
|
||||
|
||||
# Légendes des couches : titre, lecture du rendu (couleurs), méthode de calcul
|
||||
# et dégradé de colormap. Source unique partagée entre rendering.py (images
|
||||
# unitaires) et export.py (bandeau légende des mosaïques multi-dalles) — ce
|
||||
# module reste volontairement sans dépendance lourde (webapp légère : ni
|
||||
# matplotlib, ni GDAL).
|
||||
# 'gradient' : 9 arrêts échantillonnés sur la colormap matplotlib (plt.get_cmap
|
||||
# à i/8) pour dessiner une barre de dégradé sans matplotlib ; la cohérence
|
||||
# avec rendering.COLORMAPS est vérifiée par les tests (test_export.py).
|
||||
# 'ticks' : libellés des extrémités du dégradé (None = pas de barre).
|
||||
VIZ_LEGENDS = {
|
||||
'hillshade_multi': {
|
||||
'title': 'Hillshade Multidirectionnel',
|
||||
'legend': 'Illumination combinée de 8 directions (échelle fixe 0–1)\nBlanc = Face éclairée | Noir = Zone d\'ombre\nCouleurs homogènes entre tuiles',
|
||||
'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)',
|
||||
'cmap': 'gray',
|
||||
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
|
||||
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
|
||||
'ticks': ('0', '1'),
|
||||
},
|
||||
'slope': {
|
||||
'title': 'Pente (Inclinaison du terrain)',
|
||||
'legend': 'Inclinaison en degrés\nÉchelle fixe 0–30° — couleurs homogènes entre tuiles\nJaune = Forte pente | Violet foncé = Terrain plat',
|
||||
'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre',
|
||||
'cmap': 'inferno',
|
||||
'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754',
|
||||
'#e45a31', '#f98e09', '#f9cb35', '#fcffa4'),
|
||||
'ticks': ('0°', '30°'),
|
||||
},
|
||||
'aspect': {
|
||||
'title': 'Aspect (Direction des pentes)',
|
||||
'legend': 'Direction vers laquelle le terrain descend\nCycle continu : Nord→Est→Sud→Ouest→Nord\nCouleurs perceptuellement uniformes (pas de saut de teinte)',
|
||||
'description': 'Orientation des pentes — utile pour distinguer structures des formes naturelles',
|
||||
'cmap': 'twilight',
|
||||
'gradient': ('#e2d9e2', '#95b5c7', '#6276ba', '#592a8f', '#2f1436',
|
||||
'#741e4f', '#b25652', '#cca389', '#e2d9e2'),
|
||||
'ticks': ('Nord 0°', 'Nord 360°'),
|
||||
},
|
||||
'mslrm': {
|
||||
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
|
||||
'legend': 'Relief combiné multi-échelles (σ locales, échelle fixe ±3σ)\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nÉchelles : 2 à 200m, pondérées 5–20m\nCouleurs homogènes entre tuiles\nDétecte du micro au macro',
|
||||
'description': 'Combine LRM à 5–7 échelles — détecte structures de 5m à 100m simultanément',
|
||||
'cmap': 'seismic',
|
||||
'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd',
|
||||
'#ff7d7d', '#fe0000', '#be0000', '#800000'),
|
||||
'ticks': ('-3σ', '+3σ'),
|
||||
},
|
||||
'sailore': {
|
||||
'title': 'SAILORE - LRM Auto-Adaptatif',
|
||||
'legend': 'Relief local adaptatif (σ locales, échelle fixe ±3σ)\nRouge = Surélévation | Bleu = Dépression\nCouleurs homogènes entre tuiles\n\nNoyau adapté à la pente locale\nPlat = grand noyau (25m) | Pente = petit noyau (2m)',
|
||||
'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau',
|
||||
'cmap': 'seismic',
|
||||
'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd',
|
||||
'#ff7d7d', '#fe0000', '#be0000', '#800000'),
|
||||
'ticks': ('-3σ', '+3σ'),
|
||||
},
|
||||
'positive_openness': {
|
||||
'title': 'Openness Positive (ouverture vers le haut)',
|
||||
'legend': 'Angle d\'ouverture vers le ciel (z-score local)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles',
|
||||
'description': 'Ray-tracing 8 directions, multi-rayon — détecte crêtes et sommets',
|
||||
'cmap': 'YlOrBr',
|
||||
'gradient': ('#ffffe5', '#fff7bc', '#fee390', '#fec34f', '#fe9829',
|
||||
'#eb6f14', '#cb4b02', '#983404', '#662506'),
|
||||
'ticks': ('-3σ', '+3σ'),
|
||||
},
|
||||
'negative_openness': {
|
||||
'title': 'Openness Negative (ouverture vers le bas)',
|
||||
'legend': 'Angle d\'ouverture vers le bas (z-score local)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nMeilleur détecteur de cavités et dolines',
|
||||
'description': 'Ray-tracing 8 directions, multi-rayon — détecte fossés, dolines, souterrains',
|
||||
'cmap': 'PuBu',
|
||||
'gradient': ('#fff7fb', '#ece7f2', '#d0d1e6', '#a5bddb', '#73a9cf',
|
||||
'#358fc0', '#056faf', '#04598c', '#023858'),
|
||||
'ticks': ('-3σ', '+3σ'),
|
||||
},
|
||||
'svf': {
|
||||
'title': 'Sky-View Factor (fraction de ciel visible)',
|
||||
'legend': 'Proportion de ciel visible (échelle physique fixe 0–1)\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nCouleurs homogènes entre tuiles\nLes fossés ressortent en vif — excellent pour structures linéaires',
|
||||
'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre',
|
||||
'cmap': 'hot_r',
|
||||
'gradient': ('#ffffff', '#ffff81', '#ffff03', '#ffad00', '#ff5900',
|
||||
'#ff0500', '#b00000', '#5c0000', '#0b0000'),
|
||||
'ticks': ('0', '1'),
|
||||
},
|
||||
'roughness': {
|
||||
'title': 'Rugosité Multi-Échelle (3m + 15m)',
|
||||
'legend': 'Irrégularité du terrain combinée fine + large\nViolet foncé = Surface lisse (route, mur, sol plat)\nJaune vif = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)',
|
||||
'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses',
|
||||
'cmap': 'plasma',
|
||||
'gradient': ('#0d0887', '#4c02a1', '#7e03a8', '#aa2395', '#cc4778',
|
||||
'#e66c5c', '#f89540', '#fdc527', '#f0f921'),
|
||||
'ticks': ('lisse', 'rugueux'),
|
||||
},
|
||||
'wavelet': {
|
||||
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
|
||||
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
|
||||
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
|
||||
'cmap': 'inferno',
|
||||
'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754',
|
||||
'#e45a31', '#f98e09', '#f9cb35', '#fcffa4'),
|
||||
'ticks': ('bruit', 'structure'),
|
||||
},
|
||||
'flow_acc': {
|
||||
'title': 'Accumulation d\'Écoulement (Flow Accumulation)',
|
||||
'legend': 'Log10 du nombre de cellules amont\nVert foncé = Forte accumulation (fossé, chenal, drainage)\nJaune = Faible accumulation (terrain plat)\n\nDétection fossés et linéaires hydrologiques',
|
||||
'description': 'Priority-flood + D8 — détecte fossés archéologiques et drainages',
|
||||
'cmap': 'YlGn',
|
||||
'gradient': ('#ffffe5', '#f7fcb9', '#d9f0a3', '#acdd8e', '#77c679',
|
||||
'#40aa5c', '#228343', '#006737', '#004529'),
|
||||
'ticks': ('faible', 'forte'),
|
||||
},
|
||||
'solar': {
|
||||
'title': 'Éclairage Solaire',
|
||||
'legend': "Illumination solaire (azimut 90°, altitude 30°)\nClair = Face éclairée | Sombre = Zone d'ombre",
|
||||
'description': "Simulation de l'éclairage solaire matinal",
|
||||
'cmap': 'gray',
|
||||
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
|
||||
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
|
||||
'ticks': ('0', '1'),
|
||||
},
|
||||
'anomaly': {
|
||||
'title': 'Carte d\'Anomalies (détection automatique)',
|
||||
'legend': 'Score d\'anomalie composite (0–1)\nRouge = Haute anomalie (structures suspectes)\nJaune = Anomalie modérée\nBlanc = Aucun signal (terrain naturel)\n\nSeuil auto: pixels > 2σ de la moyenne locale\nCombiné: MSRM, SVF, Ondelette, Openness, Rugosité',
|
||||
'description': 'Détection automatique — cible à vérifier sur terrain',
|
||||
'cmap': 'YlOrRd',
|
||||
'gradient': ('#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8c3c',
|
||||
'#fc4d2a', '#e2191c', '#bb0026', '#800026'),
|
||||
'ticks': ('0', '1'),
|
||||
},
|
||||
'ortho': {
|
||||
'title': 'Photographie Aérienne IGN',
|
||||
'legend': 'Orthophotographie\nImage aérienne',
|
||||
'description': 'Photographie aérienne IGN (Orthophoto)',
|
||||
'cmap': None,
|
||||
'gradient': None,
|
||||
'ticks': None,
|
||||
},
|
||||
'topo': {
|
||||
'title': 'Carte Topographique IGN',
|
||||
'legend': 'Carte IGN\nPlan topographique',
|
||||
'description': 'Carte topographique IGN (Plan IGN)',
|
||||
'cmap': None,
|
||||
'gradient': None,
|
||||
'ticks': None,
|
||||
},
|
||||
}
|
||||
|
||||
# Couche activée par défaut à l'ouverture de la carte.
|
||||
DEFAULT_VIZ = 'hillshade_multi'
|
||||
|
||||
# Visualisations proposées dans le panneau de couches et le sélecteur de
|
||||
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
|
||||
# les couches réellement proposées à l'affichage (voir _render_html).
|
||||
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
|
||||
# Restriction optionnelle du panneau de couches : None = proposer toutes
|
||||
# les visualisations présentes sur disque (aucune restriction). Le sélecteur
|
||||
# de génération/régénération est piloté par le même registre (voir _render_html).
|
||||
PANEL_VIZ = None
|
||||
|
||||
# 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,
|
||||
@ -95,11 +243,10 @@ def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
|
||||
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)}
|
||||
|
||||
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
|
||||
# carte — les autres viz restent disponibles en repli dalle entière.
|
||||
# Liste vide = découper toutes les visualisations disponibles.
|
||||
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
|
||||
'wavelet')
|
||||
# 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).
|
||||
@ -108,6 +255,12 @@ _CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness'
|
||||
_SUBTILE_AVIF_QUALITY = 55
|
||||
_SUBTILE_AVIF_SPEED = 8
|
||||
|
||||
# Couches à contenu photographique ou traits fins (orthophoto, carte topo) :
|
||||
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
|
||||
# relevée pour ces couches uniquement.
|
||||
_SUBTILE_AVIF_QUALITY_DETAIL = 75
|
||||
_SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'})
|
||||
|
||||
# Vignette intermédiaire (px) : pallier entre la vignette 256 px et l'image
|
||||
# pleine résolution, pour éviter d'étirer la vignette ou de décoder l'AVIF
|
||||
# complet dès qu'une tuile dépasse ~300 px à l'écran.
|
||||
@ -423,6 +576,20 @@ def _cached_file_fresh(path, src_mtime):
|
||||
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.
|
||||
@ -513,6 +680,13 @@ def _subtile_corners(corners, i, j, 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.
|
||||
|
||||
@ -555,11 +729,15 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||||
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)
|
||||
src = output_dir / info['full']
|
||||
# source recalculée depuis — comme les vignettes). L'URL pleine
|
||||
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
|
||||
src = output_dir / info['full'].split('?')[0]
|
||||
src_mtime = _mtime(src)
|
||||
all_exist = all(
|
||||
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
|
||||
@ -574,10 +752,12 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||||
img.load()
|
||||
except Exception as e:
|
||||
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
|
||||
return None
|
||||
_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)
|
||||
@ -586,12 +766,13 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||||
quad = img.crop((left, top, right, bottom))
|
||||
try:
|
||||
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
|
||||
format='AVIF', quality=_SUBTILE_AVIF_QUALITY,
|
||||
format='AVIF', quality=avif_quality,
|
||||
speed=_SUBTILE_AVIF_SPEED)
|
||||
except Exception as e:
|
||||
logger.warning(f"Encodage AVIF impossible ({stem}), "
|
||||
f"sous-tuilage abandonné : {e}")
|
||||
return None
|
||||
f"repli dalle entière pour {viz_key} : {e}")
|
||||
cut_failed = True
|
||||
break
|
||||
# Supprime l'ancien crop .webp d'une génération précédente
|
||||
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
|
||||
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
|
||||
@ -607,11 +788,15 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||||
max(1, int(quad.size[1] * scale))), resample)
|
||||
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
|
||||
format='WEBP', quality=80)
|
||||
if cut_failed:
|
||||
_fallback_full_dalle(entries, viz_key, info)
|
||||
continue
|
||||
v = _url_version(src_mtime)
|
||||
for (i, j), stem in stems.items():
|
||||
entries[(i, j)]['viz'][viz_key] = {
|
||||
'thumb': f"{sub_dir_name}/{stem}_thumb.webp",
|
||||
'mid': f"{sub_dir_name}/{stem}_mid.webp",
|
||||
'full': f"{sub_dir_name}/{stem}.avif",
|
||||
'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}",
|
||||
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
|
||||
'full': f"{sub_dir_name}/{stem}.avif{v}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
|
||||
@ -620,13 +805,12 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||||
usable = [e for e in entries.values() if e['viz']]
|
||||
if not usable:
|
||||
return None
|
||||
# Repli dalle entière pour les visualisations non découpées : elles restent
|
||||
# superposables en couche (images plus lourdes, mais fonctionnelles).
|
||||
# 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 entry in usable:
|
||||
for viz_key, info in tile['viz'].items():
|
||||
if viz_key not in sub_keys:
|
||||
entry['viz'][viz_key] = dict(info)
|
||||
for viz_key, info in tile['viz'].items():
|
||||
if viz_key not in sub_keys:
|
||||
_fallback_full_dalle(entries, viz_key, info)
|
||||
return usable
|
||||
|
||||
|
||||
@ -799,12 +983,13 @@ def build_index(output_dir, output_format='avif'):
|
||||
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}",
|
||||
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
|
||||
'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}"
|
||||
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}{v}"
|
||||
|
||||
if regen:
|
||||
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
|
||||
@ -909,9 +1094,11 @@ def _panel_selection(all_viz_keys):
|
||||
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).
|
||||
"""
|
||||
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
|
||||
# les autres visualisations existent sur disque mais ne sont pas proposées.
|
||||
panel_keys = set(all_viz_keys)
|
||||
# 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).
|
||||
@ -919,10 +1106,12 @@ def _panel_selection(all_viz_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, sinon aspect (base
|
||||
# complète), sinon la 1re proposée.
|
||||
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
|
||||
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
|
||||
# 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
|
||||
|
||||
|
||||
@ -932,13 +1121,13 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
||||
|
||||
tiles_json = json.dumps(tiles, ensure_ascii=False)
|
||||
|
||||
# Options du sélecteur de génération/régénération : uniquement les couches
|
||||
# réellement proposées dans le panneau de visualisation (mêmes couches,
|
||||
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Toutes
|
||||
# pré-sélectionnées : une régénération refait la dalle complète du panneau,
|
||||
# l'utilisateur pouvant désélectionner pour cibler. Repli sur aspect si
|
||||
# aucune couche n'est encore disponible.
|
||||
gen_keys = ordered_viz if ordered_viz else ['aspect']
|
||||
# Options du sélecteur de génération/régénération : TOUTES les couches du
|
||||
# registre (mêmes libellés que le panneau ; 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_keys = [v for v in _VIZ_FALLBACK_ORDER if v in VIZ_LABELS]
|
||||
gen_keys += sorted(k for k in VIZ_LABELS if k not in gen_keys)
|
||||
gen_viz_options = "\n".join(
|
||||
f' <option value="{KEYWORD_TO_STEP.get(v, v)}" selected>'
|
||||
f'{VIZ_LABELS.get(v, v)}</option>'
|
||||
@ -996,6 +1185,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<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">
|
||||
@ -1013,9 +1203,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" id="tab-gen" hidden>
|
||||
<!-- Actions Zone / Compléter : réservées à cet onglet -->
|
||||
<!-- 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="Régénérer des tuiles précises : cliquer des dalles sur la carte pour les ajouter ou les retirer" 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>
|
||||
|
||||
@ -1027,7 +1218,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
</label>
|
||||
<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="4">
|
||||
<select id="genViz" class="gen-select" multiple size="8">
|
||||
{gen_viz_options}
|
||||
</select>
|
||||
</label>
|
||||
@ -1070,6 +1261,53 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<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>
|
||||
|
||||
@ -1377,8 +1615,8 @@ body.zooming .ltile { will-change: transform; }
|
||||
/* 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 { background: var(--accent-dim); border-color: var(--accent-dim); }
|
||||
#btnGenZone:hover { background: var(--accent); border-color: var(--accent); }
|
||||
#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;
|
||||
@ -1474,6 +1712,46 @@ body.zooming .ltile { will-change: transform; }
|
||||
.gen-tile.done .gen-tile-state { color: #39d98a; }
|
||||
.gen-tile.failed .gen-tile-state { color: var(--danger); }
|
||||
|
||||
/* --- Onglet Export : mosaïque de dalles adjacentes (téléphone) --- */
|
||||
.export-intro {
|
||||
font-size: 12px; color: var(--muted); line-height: 1.5; padding: 4px 2px 0;
|
||||
}
|
||||
#btnExportSel { background: var(--accent-dim); border-color: var(--accent-dim); }
|
||||
#btnExportSel:hover { background: var(--accent); border-color: var(--accent); }
|
||||
.export-count { font-size: 12.5px; font-weight: 600; }
|
||||
.export-count .sub { font-weight: 400; color: var(--muted); font-size: 11px; }
|
||||
#exportBar {
|
||||
display: none; flex-direction: column; gap: 8px;
|
||||
margin-top: 8px; padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--panel-border); border-radius: 8px;
|
||||
}
|
||||
#exportBar.show { display: flex; }
|
||||
#exportMsg { font-size: 12.5px; line-height: 1.45; }
|
||||
#exportDone { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; }
|
||||
#exportDoneList { display: flex; flex-direction: column; gap: 6px; }
|
||||
#exportDoneList a {
|
||||
font-size: 11.5px; color: var(--accent); text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
#exportDoneList a:hover { text-decoration: underline; }
|
||||
#exportDoneList .dl-ic { margin-right: 4px; }
|
||||
/* Surlignage vert des dalles retenues pour l'export (surcouche écran
|
||||
uniquement — l'image exportée colle les dalles sans aucun trait). */
|
||||
.ltile.exportSel {
|
||||
border-color: #39d98a;
|
||||
box-shadow: 0 0 0 1px #39d98a, 0 0 12px rgba(57, 217, 138, 0.5);
|
||||
}
|
||||
body.exporting, body.exporting #viewport { cursor: pointer; }
|
||||
|
||||
/* Surlignage bleu des dalles retenues pour la régénération (mode
|
||||
« Sélectionner des tuiles » de l'onglet Génération). */
|
||||
.ltile.genSel {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5);
|
||||
}
|
||||
body.genSel, body.genSel #viewport { cursor: pointer; }
|
||||
|
||||
/* Barres de défilement fines et discrètes */
|
||||
.tab-page::-webkit-scrollbar,
|
||||
.gen-task-log::-webkit-scrollbar { width: 8px; }
|
||||
@ -1617,7 +1895,11 @@ function registerTile(t) {
|
||||
interactive: true,
|
||||
keyboard: false
|
||||
}).addTo(map);
|
||||
mk.on('click', () => { if (!genMode) showTileInfo(t); });
|
||||
mk.on('click', () => {
|
||||
if (exportMode) { toggleExportSel(t); return; }
|
||||
if (genSelMode) { toggleGenSel(t); return; }
|
||||
if (!genMode) showTileInfo(t);
|
||||
});
|
||||
const el = mk.getElement();
|
||||
markers.set(tileKey(t), {
|
||||
mk: mk, t: t,
|
||||
@ -2105,7 +2387,11 @@ function hideTileInfo() {
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') hideTileInfo();
|
||||
if (e.key === 'Escape') {
|
||||
hideTileInfo();
|
||||
if (exportMode) setExportMode(false);
|
||||
if (genSelMode) setGenSelMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
function regenerateCurrentTile() {
|
||||
@ -2126,6 +2412,7 @@ function regenerateCurrentTile() {
|
||||
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');
|
||||
@ -2144,6 +2431,8 @@ const genQueueCount = document.getElementById('genQueueCount');
|
||||
let genMode = false, drawStart = null, drawRect = null;
|
||||
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
|
||||
const ghostCells = [];
|
||||
let genSelMode = false;
|
||||
const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération
|
||||
|
||||
// Affiche la barre de confirmation de génération, dans l'onglet Génération
|
||||
// (bascule automatique sur cet onglet).
|
||||
@ -2155,17 +2444,16 @@ function showGenBar(msg) {
|
||||
|
||||
fetch('/api/status').then(r => {
|
||||
apiAvailable = r.ok;
|
||||
if (!apiAvailable) hideExportTab();
|
||||
return r.ok ? r.json() : null;
|
||||
}).then(d => {
|
||||
if (!d) { if (apiAvailable) { genBtn.hidden = false; btnComplete.hidden = false; } return; }
|
||||
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) {
|
||||
genBtn.hidden = !regenAllowed;
|
||||
btnComplete.hidden = !regenAllowed;
|
||||
}
|
||||
}).catch(() => {});
|
||||
if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = !regenAllowed; });
|
||||
}).catch(() => { if (!apiAvailable) hideExportTab(); });
|
||||
|
||||
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
|
||||
|
||||
@ -2187,6 +2475,7 @@ function exitGenMode() {
|
||||
genBtn.addEventListener('click', () => {
|
||||
if (genMode) { exitGenMode(); return; }
|
||||
clearGhosts();
|
||||
clearGenSel();
|
||||
genBar.classList.remove('show');
|
||||
genMode = true;
|
||||
map.dragging.disable();
|
||||
@ -2239,12 +2528,92 @@ map.on('mouseup', e => {
|
||||
}).catch(() => { showGenBar('API indisponible'); });
|
||||
});
|
||||
|
||||
// « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une
|
||||
// dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues
|
||||
// (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de
|
||||
// confirmation permet ensuite de choisir les couches et les options avant de
|
||||
// lancer la régénération (--force --force-classification) sur la sélection.
|
||||
function genSelKey(col, row) { return col + ',' + row; }
|
||||
|
||||
function refreshGenSelHighlights() {
|
||||
for (const entry of markers.values()) {
|
||||
const div = entryDiv(entry);
|
||||
if (!div) continue;
|
||||
div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row)));
|
||||
}
|
||||
}
|
||||
|
||||
function genSelCells() {
|
||||
return [...genSel].map(k => k.split(',').map(Number));
|
||||
}
|
||||
|
||||
function updateGenSelBar() {
|
||||
showGenBar(genSel.size
|
||||
? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'
|
||||
: 'Mode sélection : clic sur dalle = ajouter / retirer');
|
||||
genGo.style.display = genSel.size ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleGenSel(t) {
|
||||
const key = genSelKey(t.col, t.row);
|
||||
if (genSel.has(key)) genSel.delete(key);
|
||||
else genSel.add(key);
|
||||
refreshGenSelHighlights();
|
||||
pendingTiles = genSelCells();
|
||||
pendingRegen = true;
|
||||
pendingCompleteAll = false;
|
||||
updateGenSelBar();
|
||||
}
|
||||
|
||||
function setGenSelMode(on) {
|
||||
genSelMode = on;
|
||||
document.body.classList.toggle('genSel', on);
|
||||
if (btnGenSel) {
|
||||
btnGenSel.classList.toggle('primary', on);
|
||||
btnGenSel.innerHTML = on
|
||||
? '✓ Sélection en cours — cliquer des dalles'
|
||||
: '↻ Sélectionner des tuiles';
|
||||
}
|
||||
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
|
||||
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider).
|
||||
if (on && document.body.classList.contains('menu-open')) {
|
||||
document.body.classList.remove('menu-open');
|
||||
}
|
||||
if (hintChip) {
|
||||
hintChip.innerHTML = on
|
||||
? 'Mode sélection : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
|
||||
: hintDefaultHtml;
|
||||
}
|
||||
if (on) {
|
||||
if (genMode) exitGenMode();
|
||||
clearGhosts();
|
||||
pendingCompleteAll = false;
|
||||
genRegen.checked = true; // régénération ciblée : retraitement forcé
|
||||
switchTab('gen');
|
||||
updateGenSelBar();
|
||||
}
|
||||
}
|
||||
|
||||
// Quitte le mode sélection et vide la sélection (changement de mode).
|
||||
function clearGenSel() {
|
||||
if (genSelMode) setGenSelMode(false);
|
||||
if (genSel.size) {
|
||||
genSel.clear();
|
||||
refreshGenSelHighlights();
|
||||
}
|
||||
}
|
||||
|
||||
if (btnGenSel) btnGenSel.addEventListener('click', () => {
|
||||
setGenSelMode(!genSelMode);
|
||||
});
|
||||
|
||||
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
|
||||
// moins une des visualisations choisies (pas de téléchargement) — la barre de
|
||||
// confirmation reste utilisable (choix des couches, classification, Lancer).
|
||||
btnComplete.addEventListener('click', () => {
|
||||
if (!apiAvailable) return;
|
||||
clearGhosts();
|
||||
clearGenSel();
|
||||
genGo.style.display = 'none';
|
||||
showGenBar('Analyse des tuiles présentes dans input/...');
|
||||
fetch('/api/preview', {
|
||||
@ -2274,6 +2643,7 @@ genGo.addEventListener('click', () => {
|
||||
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,
|
||||
@ -2288,6 +2658,8 @@ genGo.addEventListener('click', () => {
|
||||
const verb = isRegen ? 'régénération'
|
||||
: pendingCompleteAll ? 'complétion' : 'génération';
|
||||
pendingCompleteAll = false;
|
||||
pendingRegen = false;
|
||||
if (fromSel) clearGenSel();
|
||||
return startGenTask(nTiles + ' tuile(s) — ' + verb);
|
||||
})
|
||||
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
|
||||
@ -2297,9 +2669,219 @@ genCancel.addEventListener('click', () => {
|
||||
genBar.classList.remove('show');
|
||||
clearGhosts();
|
||||
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
|
||||
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
|
||||
@ -2440,7 +3022,10 @@ function finishRunningTasks(d) {
|
||||
// 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.
|
||||
// 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;
|
||||
function mergeTilesData(data) {
|
||||
if (!data || !Array.isArray(data.tiles)) return;
|
||||
@ -2607,6 +3192,16 @@ 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)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user