924 lines
31 KiB
Python
924 lines
31 KiB
Python
"""Carte continue interactive des tuiles LiDAR traitées.
|
||
|
||
Génère une page HTML unique (output/index.html) présentant toutes les tuiles
|
||
1×1 km traitées, affichées en carte continue sans bordure, avec zoom/pan fluide
|
||
et sélecteur de visualisation. Au clic sur une tuile, un modal affiche l'image
|
||
avec légende et permet de télécharger en PDF.
|
||
|
||
Sortie:
|
||
- output/index.html : page interactive auto-suffisante
|
||
- output/index_thumbs/*.jpg : vignettes JPEG (~256px) par tuile/visualisation
|
||
|
||
Intégration:
|
||
- Appelé automatiquement à la fin de process_all() dans pipeline.py
|
||
- Régénération solo via --rebuild-index dans cli.py
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from pathlib import Path
|
||
|
||
logger = logging.getLogger("lidar")
|
||
|
||
|
||
# Noms d'affichage (français) pour le sélecteur de visualisation.
|
||
# Clé = mot-clé dans le nom de fichier de sortie (post-basename).
|
||
VIZ_LABELS = {
|
||
'hillshade_multi': 'Hillshade multidirectionnel',
|
||
'slope': 'Pente',
|
||
'aspect': 'Aspect',
|
||
'mslrm': 'MSRM (relief multi-échelle)',
|
||
'sailore': 'SAILORE (LRM adaptatif)',
|
||
'positive_openness': 'Openness positive',
|
||
'negative_openness': 'Openness négative',
|
||
'svf': 'Sky-View Factor',
|
||
'aniso_open': 'Openness anisotropique',
|
||
'roughness': 'Rugosité',
|
||
'wavelet': 'Ondelette',
|
||
'flow_acc': 'Accumulation d\'écoulement',
|
||
'solar': 'Éclairage solaire',
|
||
'anomaly': 'Carte d\'anomalies',
|
||
'ortho': 'Orthophoto IGN',
|
||
'topo': 'Carte topographique IGN',
|
||
}
|
||
|
||
# Informations de colormap pour génération de légende côté navigateur.
|
||
# Utilisées par le JS pour dessiner la légende sur le canvas avant export PDF.
|
||
VIZ_COLORMAPS = {
|
||
'hillshade_multi': {'cmap': 'gray', 'diverging': False},
|
||
'slope': {'cmap': 'inferno', 'diverging': False},
|
||
'aspect': {'cmap': 'twilight', 'diverging': False},
|
||
'mslrm': {'cmap': 'seismic', 'diverging': True},
|
||
'sailore': {'cmap': 'seismic', 'diverging': True},
|
||
'positive_openness': {'cmap': 'YlOrBr', 'diverging': False},
|
||
'negative_openness': {'cmap': 'PuBu', 'diverging': False},
|
||
'svf': {'cmap': 'hot_r', 'diverging': False},
|
||
'aniso_open': {'cmap': 'seismic', 'diverging': True},
|
||
'roughness': {'cmap': 'plasma', 'diverging': False},
|
||
'wavelet': {'cmap': 'cividis', 'diverging': False},
|
||
'flow_acc': {'cmap': 'YlGn', 'diverging': False},
|
||
'solar': {'cmap': 'gray', 'diverging': False},
|
||
'anomaly': {'cmap': 'YlOrRd', 'diverging': False},
|
||
'ortho': {'cmap': 'rgb', 'diverging': False},
|
||
'topo': {'cmap': 'rgb', 'diverging': False},
|
||
}
|
||
|
||
# Visualisation par défaut pour la vignette (si disponible).
|
||
DEFAULT_VIZ = 'hillshade_multi'
|
||
|
||
# Ordre préféré pour le choix de la vignette de repli.
|
||
_VIZ_FALLBACK_ORDER = [
|
||
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
|
||
'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness',
|
||
'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo',
|
||
]
|
||
|
||
# Regex pour parser les coordonnées tuile dans le basename LHD.
|
||
# LHD_FXX_{COL}_{ROW}_PTS_LAMB93_IGN69 (COL/ROW en km, Lambert 93)
|
||
_RE_LHD_COORDS = re.compile(r'^LHD_FXX_(\d+)_(\d+)_PTS_LAMB93')
|
||
|
||
|
||
def parse_basename_coords(name):
|
||
"""Extrait les coordonnées tuile (col, row en km) depuis un basename.
|
||
|
||
Args:
|
||
name: basename potentiel (ex: 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69')
|
||
ou nom de dossier avec suffixe résolution ('..._r0p2').
|
||
|
||
Returns:
|
||
(col_km, row_km) ou None si le nom ne correspond pas au pattern LHD.
|
||
"""
|
||
m = _RE_LHD_COORDS.match(name)
|
||
if not m:
|
||
return None
|
||
return int(m.group(1)), int(m.group(2))
|
||
|
||
|
||
def _strip_res_suffix(dirname):
|
||
"""Sépare le basename de base et la résolution d'un nom de dossier de viz.
|
||
|
||
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69' → (basename, 0.5)
|
||
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2' → (basename, 0.2)
|
||
|
||
Returns:
|
||
(basename_without_suffix, resolution_float) ou (dirname, 0.5) si pas de suffixe.
|
||
"""
|
||
m = re.match(r'^(.+?)_r(\d+p\d+)$', dirname)
|
||
if m:
|
||
res_str = m.group(2).replace('p', '.')
|
||
try:
|
||
return m.group(1), float(res_str)
|
||
except ValueError:
|
||
pass
|
||
return dirname, 0.5
|
||
|
||
|
||
def scan_tiles(vis_dir):
|
||
"""Scanne le dossier des visualisations pour inventorier les tuiles traitées.
|
||
|
||
Args:
|
||
vis_dir: Path vers output/visualisations/
|
||
|
||
Returns:
|
||
Liste de dictionnaires:
|
||
{basename, col, row, resolution, dir_path,
|
||
viz: {viz_key: {filename, ext}}, dir_name}
|
||
Triée par (resolution, row, col).
|
||
"""
|
||
vis_dir = Path(vis_dir)
|
||
if not vis_dir.is_dir():
|
||
return []
|
||
|
||
tiles = []
|
||
for entry in sorted(vis_dir.iterdir()):
|
||
if not entry.is_dir():
|
||
continue
|
||
coords = parse_basename_coords(entry.name)
|
||
if coords is None:
|
||
continue
|
||
col, row = coords
|
||
basename, resolution = _strip_res_suffix(entry.name)
|
||
|
||
# Liste les fichiers image de visualisation dans le dossier.
|
||
viz = {}
|
||
for f in sorted(entry.iterdir()):
|
||
if not f.is_file():
|
||
continue
|
||
# Détection extension AVIF/WebP
|
||
ext = None
|
||
low = f.name.lower()
|
||
for e in ('.avif', '.webp'):
|
||
if low.endswith(e):
|
||
ext = e.lstrip('.')
|
||
break
|
||
if ext is None:
|
||
continue
|
||
# viz_key = nom sans le préfixe basename_ ni l'extension
|
||
stem = f.name[:-len('.' + ext)]
|
||
prefix = basename + '_'
|
||
if not stem.startswith(prefix):
|
||
continue
|
||
viz_key = stem[len(prefix):]
|
||
viz[viz_key] = {'filename': f.name, 'ext': ext}
|
||
|
||
if not viz:
|
||
# Dossier vide ou sans image valide → ignoré
|
||
continue
|
||
|
||
tiles.append({
|
||
'basename': basename,
|
||
'col': col,
|
||
'row': row,
|
||
'resolution': resolution,
|
||
'dir_name': entry.name,
|
||
'dir_path': str(entry),
|
||
'viz': viz,
|
||
})
|
||
|
||
tiles.sort(key=lambda t: (t['resolution'], -t['row'], t['col']))
|
||
return tiles
|
||
|
||
|
||
def compute_bbox(tiles):
|
||
"""Calcule la bounding box (en km) couverte par les tuiles.
|
||
|
||
Returns:
|
||
Dict {min_col, max_col, min_row, max_row} ou None si aucune tuile.
|
||
"""
|
||
if not tiles:
|
||
return None
|
||
cols = [t['col'] for t in tiles]
|
||
rows = [t['row'] for t in tiles]
|
||
return {
|
||
'min_col': min(cols),
|
||
'max_col': max(cols),
|
||
'min_row': min(rows),
|
||
'max_row': max(rows),
|
||
}
|
||
|
||
|
||
def generate_thumbnail(src_path, thumb_path, max_size=256):
|
||
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
|
||
|
||
Args:
|
||
src_path: chemin de l'image source (AVIF/WebP).
|
||
thumb_path: chemin de sortie JPEG.
|
||
max_size: taille maximale (côté le plus grand) en pixels.
|
||
|
||
Returns:
|
||
True si OK, False si échec.
|
||
"""
|
||
try:
|
||
from PIL import Image as PILImage
|
||
except ImportError:
|
||
logger.warning("PIL indisponible — impossible de générer les vignettes")
|
||
return False
|
||
|
||
try:
|
||
img = PILImage.open(str(src_path))
|
||
img = img.convert('RGB')
|
||
w, h = img.size
|
||
scale = min(1.0, max_size / max(w, h))
|
||
if scale < 1.0:
|
||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||
try:
|
||
resample = PILImage.Resampling.LANCZOS
|
||
except AttributeError:
|
||
resample = getattr(PILImage, 'LANCZOS', 1)
|
||
img = img.resize(new_size, resample)
|
||
Path(thumb_path).parent.mkdir(parents=True, exist_ok=True)
|
||
img.save(str(thumb_path), format='JPEG', quality=80)
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(f"Vignette ignorée {src_path}: {e}")
|
||
return False
|
||
|
||
|
||
def _pick_display_viz(viz_keys):
|
||
"""Choisit la visualisation par défaut pour une tuile.
|
||
|
||
Privilégie hillshade_multi, sinon la première disponible selon l'ordre de repli.
|
||
"""
|
||
for v in _VIZ_FALLBACK_ORDER:
|
||
if v in viz_keys:
|
||
return v
|
||
return sorted(viz_keys)[0]
|
||
|
||
|
||
def build_index(output_dir, output_format='avif'):
|
||
"""Génère la carte continue HTML des tuiles traitées.
|
||
|
||
Scanne output_dir/visualisations/, génère les vignettes JPEG, puis écrit
|
||
output_dir/index.html (auto-suffisant) + output_dir/index_thumbs/.
|
||
|
||
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'
|
||
tiles = scan_tiles(vis_dir)
|
||
|
||
if not tiles:
|
||
logger.info("Aucune tuile traitée trouvée — index global non généré")
|
||
return None
|
||
|
||
bbox = compute_bbox(tiles)
|
||
assert bbox is not None # garanti par le test tiles non vide ci-dessus
|
||
thumb_dir = output_dir / 'index_thumbs'
|
||
thumb_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Collecte toutes les visualisations disponibles (pour le sélecteur).
|
||
all_viz_keys = set()
|
||
for t in tiles:
|
||
all_viz_keys.update(t['viz'].keys())
|
||
|
||
# Génère les vignettes et construit les données pour le HTML.
|
||
tile_records = []
|
||
thumbs_generated = 0
|
||
thumbs_failed = 0
|
||
for t in tiles:
|
||
viz_thumbs = {}
|
||
for viz_key, info in t['viz'].items():
|
||
src = Path(t['dir_path']) / info['filename']
|
||
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
|
||
thumb_path = thumb_dir / thumb_name
|
||
# Régénère seulement si manquante
|
||
if not thumb_path.exists():
|
||
if generate_thumbnail(src, thumb_path):
|
||
thumbs_generated += 1
|
||
else:
|
||
thumbs_failed += 1
|
||
continue
|
||
else:
|
||
thumbs_generated += 1
|
||
viz_thumbs[viz_key] = {
|
||
'thumb': f"index_thumbs/{thumb_name}",
|
||
'full': f"visualisations/{t['dir_name']}/{info['filename']}",
|
||
}
|
||
|
||
if not viz_thumbs:
|
||
continue
|
||
|
||
display_viz = _pick_display_viz(viz_thumbs.keys())
|
||
tile_records.append({
|
||
'col': t['col'],
|
||
'row': t['row'],
|
||
'name': t['basename'],
|
||
'dir_name': t['dir_name'],
|
||
'resolution': t['resolution'],
|
||
'display_viz': display_viz,
|
||
'viz': viz_thumbs,
|
||
})
|
||
|
||
if not tile_records:
|
||
logger.warning("Aucune vignette générée — index global abandonné")
|
||
return None
|
||
|
||
# HTML avec données intégrées.
|
||
html = _render_html(tile_records, 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}")
|
||
logger.info(f" {len(tile_records)} tuile(s) • {thumbs_generated} vignette(s) générée(s)"
|
||
+ (f" • {thumbs_failed} échec(s)" if thumbs_failed else ""))
|
||
logger.info(f" Grille : {bbox['min_col']}-{bbox['max_col']} km E × "
|
||
f"{bbox['min_row']}-{bbox['max_row']} km N")
|
||
|
||
return html_path
|
||
|
||
|
||
def _render_html(tile_records, bbox, all_viz_keys, output_format):
|
||
"""Construit le HTML complet avec CSS et JS (carte continue + modal PDF)."""
|
||
# Ordre des viz dans le sélecteur (selon ordre préféré puis alpha).
|
||
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in all_viz_keys]
|
||
for v in sorted(all_viz_keys):
|
||
if v not in ordered_viz:
|
||
ordered_viz.append(v)
|
||
|
||
data_json = json.dumps({
|
||
'tiles': tile_records,
|
||
'bbox': bbox,
|
||
'vizList': ordered_viz,
|
||
}, ensure_ascii=False)
|
||
|
||
viz_meta_json = json.dumps({
|
||
k: {'label': VIZ_LABELS.get(k, k), 'cmap': VIZ_COLORMAPS.get(k, {}).get('cmap', 'terrain'),
|
||
'diverging': VIZ_COLORMAPS.get(k, {}).get('diverging', False)}
|
||
for k in ordered_viz
|
||
}, ensure_ascii=False)
|
||
|
||
options_html = '\n'.join(
|
||
f' <option value="{v}"{" selected" if v == DEFAULT_VIZ else ""}>'
|
||
f'{VIZ_LABELS.get(v, v)}</option>'
|
||
for v in ordered_viz
|
||
)
|
||
|
||
n_tiles = len(tile_records)
|
||
grid_w = bbox['max_col'] - bbox['min_col'] + 1
|
||
grid_h = bbox['max_row'] - bbox['min_row'] + 1
|
||
|
||
return _HTML_TEMPLATE.format(
|
||
data_json=data_json,
|
||
viz_meta_json=viz_meta_json,
|
||
options_html=options_html,
|
||
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 continue LiDAR</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
html, body {{ height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0a0a14; color: #e0e0e0; overflow: hidden; }}
|
||
|
||
#topbar {{
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||
padding: 10px 16px; background: #16213e; border-bottom: 1px solid #0f3460;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||
}}
|
||
#topbar h1 {{ font-size: 16px; font-weight: 600; color: #e94560; white-space: nowrap; }}
|
||
#topbar .stat {{ font-size: 13px; color: #a0a0c0; white-space: nowrap; }}
|
||
#topbar label {{ font-size: 13px; color: #a0a0c0; }}
|
||
#topbar select {{
|
||
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
|
||
padding: 5px 8px; border-radius: 4px; font-size: 13px; cursor: pointer;
|
||
}}
|
||
#topbar select:hover {{ border-color: #e94560; }}
|
||
.spacer {{ flex: 1; }}
|
||
|
||
.controls {{ display: flex; gap: 6px; }}
|
||
.controls button {{
|
||
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
|
||
width: 34px; height: 34px; border-radius: 4px; font-size: 18px;
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
transition: background 0.15s;
|
||
}}
|
||
.controls button:hover {{ background: #1a4a7a; border-color: #e94560; }}
|
||
.controls button:active {{ background: #e94560; }}
|
||
|
||
#viewport {{
|
||
position: absolute; top: 56px; left: 0; right: 0; bottom: 0;
|
||
overflow: hidden; cursor: grab; background: #0a0a14;
|
||
}}
|
||
#viewport.dragging {{ cursor: grabbing; }}
|
||
#viewport.click-tile {{ cursor: pointer; }}
|
||
|
||
#grid {{
|
||
position: absolute; transform-origin: 0 0;
|
||
}}
|
||
|
||
.tile {{
|
||
position: absolute;
|
||
width: {grid_w}000px; height: {grid_h}000px;
|
||
overflow: hidden;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: #111122;
|
||
}}
|
||
.tile.has-data {{ background: #0d0d1a; }}
|
||
.tile img {{
|
||
width: 100%; height: 100%; object-fit: contain; display: block;
|
||
image-rendering: auto;
|
||
}}
|
||
.tile .coord-label {{
|
||
position: absolute; top: 2px; left: 4px;
|
||
font-size: 9px; color: rgba(224,224,224,0.4);
|
||
pointer-events: none; user-select: none;
|
||
}}
|
||
|
||
#zoomIndicator {{
|
||
position: fixed; bottom: 12px; left: 12px; z-index: 100;
|
||
background: rgba(22,33,62,0.9); padding: 6px 12px; border-radius: 4px;
|
||
font-size: 12px; color: #a0a0c0; border: 1px solid #0f3460;
|
||
}}
|
||
|
||
#hint {{
|
||
position: fixed; bottom: 12px; right: 12px; z-index: 100;
|
||
background: rgba(22,33,62,0.9); padding: 8px 12px; border-radius: 4px;
|
||
font-size: 11px; color: #808090; border: 1px solid #0f3460; max-width: 280px;
|
||
}}
|
||
|
||
/* --- Modal overlay --- */
|
||
#modal {{
|
||
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
z-index: 200; background: rgba(0,0,0,0.75);
|
||
align-items: center; justify-content: center;
|
||
}}
|
||
#modal.active {{ display: flex; }}
|
||
|
||
#modal-content {{
|
||
position: relative; max-width: 90vw; max-height: 90vh;
|
||
background: #16213e; border-radius: 8px; overflow: hidden;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
|
||
display: flex; flex-direction: column;
|
||
}}
|
||
#modal-header {{
|
||
padding: 12px 16px; display: flex; align-items: center; justify-content: space-between;
|
||
border-bottom: 1px solid #0f3460;
|
||
}}
|
||
#modal-header h2 {{ font-size: 15px; color: #e94560; }}
|
||
#modal-close {{
|
||
background: none; border: none; color: #e0e0e0; font-size: 22px;
|
||
cursor: pointer; padding: 4px 8px;
|
||
}}
|
||
#modal-close:hover {{ color: #e94560; }}
|
||
|
||
#modal-body {{
|
||
display: flex; flex-direction: row; padding: 12px; gap: 12px;
|
||
overflow: auto; max-height: calc(90vh - 80px);
|
||
}}
|
||
#modal-canvas {{ flex: 1; min-width: 0; }}
|
||
#modal-canvas canvas {{
|
||
width: 100%; height: auto; display: block; border-radius: 4px;
|
||
}}
|
||
|
||
#modal-actions {{
|
||
display: flex; flex-direction: column; gap: 8px; padding: 12px 16px;
|
||
border-top: 1px solid #0f3460;
|
||
}}
|
||
#modal-actions button {{
|
||
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
|
||
padding: 8px 16px; border-radius: 4px; font-size: 13px; cursor: pointer;
|
||
transition: background 0.15s;
|
||
}}
|
||
#modal-actions button:hover {{ background: #1a4a7a; border-color: #e94560; }}
|
||
#modal-actions button.primary {{ background: #e94560; border-color: #e94560; }}
|
||
#modal-actions button.primary:hover {{ background: #c73652; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="topbar">
|
||
<h1>Carte continue LiDAR</h1>
|
||
<span class="stat" id="statTiles">{n_tiles} tuile(s) • {grid_w}×{grid_h} km</span>
|
||
<label>Visualisation :</label>
|
||
<select id="vizSelect">
|
||
{options_html}
|
||
</select>
|
||
<div class="spacer"></div>
|
||
<div class="controls">
|
||
<button id="zoomOut" title="Dézoomer">−</button>
|
||
<button id="zoomReset" title="Réinitialiser la vue" style="font-size:14px">⤢</button>
|
||
<button id="zoomIn" title="Zoomer">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="viewport">
|
||
<div id="grid"></div>
|
||
</div>
|
||
|
||
<div id="zoomIndicator">Zoom : 100%</div>
|
||
<div id="hint">
|
||
Molette : zoom • Clic-glisser : déplacer<br>
|
||
Clic sur tuile : ouvrir avec légende<br>
|
||
Double-clic : zoom rapide
|
||
</div>
|
||
|
||
<!-- Modal pour affichage avec légende -->
|
||
<div id="modal">
|
||
<div id="modal-content">
|
||
<div id="modal-header">
|
||
<h2 id="modal-title">Titre</h2>
|
||
<button id="modal-close">×</button>
|
||
</div>
|
||
<div id="modal-body">
|
||
<div id="modal-canvas"><canvas id="legendCanvas"></canvas></div>
|
||
</div>
|
||
<div id="modal-actions">
|
||
<button id="downloadPdf" class="primary">Télécharger PDF</button>
|
||
<button id="downloadFull">Télécharger image complète</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||
<script>
|
||
const DATA = {data_json};
|
||
const VIZ_META = {viz_meta_json};
|
||
const CELL_PX = 256; // taille d'une cellule 1×1 km en pixels
|
||
const bbox = DATA.bbox;
|
||
const gridCols = bbox.max_col - bbox.min_col + 1;
|
||
const gridRows = bbox.max_row - bbox.min_row + 1;
|
||
|
||
const grid = document.getElementById('grid');
|
||
const viewport = document.getElementById('viewport');
|
||
const zoomInd = document.getElementById('zoomIndicator');
|
||
const vizSelect = document.getElementById('vizSelect');
|
||
|
||
// Dimensions de la grille en px
|
||
grid.style.width = (gridCols * CELL_PX) + 'px';
|
||
grid.style.height = (gridRows * CELL_PX) + 'px';
|
||
|
||
// Index des tuiles par (col,row)
|
||
const tileMap = {{}};
|
||
for (const t of DATA.tiles) {{
|
||
tileMap[t.col + ',' + t.row] = t;
|
||
}}
|
||
|
||
// --- Construction de la grille ---
|
||
function colToX(col) {{ return (col - bbox.min_col) * CELL_PX; }}
|
||
function rowToY(row) {{ return (bbox.max_row - row) * CELL_PX; }} // Y inversé (Nord en haut)
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
for (let r = bbox.max_row; r >= bbox.min_row; r--) {{
|
||
for (let c = bbox.min_col; c <= bbox.max_col; c++) {{
|
||
const key = c + ',' + r;
|
||
const tile = tileMap[key];
|
||
const div = document.createElement('div');
|
||
div.className = 'tile' + (tile ? ' has-data' : '');
|
||
div.style.left = colToX(c) + 'px';
|
||
div.style.top = rowToY(r) + 'px';
|
||
div.style.width = CELL_PX + 'px';
|
||
div.style.height = CELL_PX + 'px';
|
||
div.dataset.col = c;
|
||
div.dataset.row = r;
|
||
|
||
if (tile) {{
|
||
div.title = tile.name;
|
||
const img = document.createElement('img';
|
||
img.loading = 'lazy';
|
||
div.appendChild(img);
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'coord-label';
|
||
label.textContent = `${{c}},${{r}}`;
|
||
div.appendChild(label);
|
||
|
||
// Click handler for modal
|
||
div.addEventListener('click', (e) => {{
|
||
if (isDragging) return;
|
||
openModal(c, r);
|
||
}});
|
||
}}
|
||
fragment.appendChild(div);
|
||
}}
|
||
}}
|
||
grid.appendChild(fragment);
|
||
|
||
// --- Gestion de la visualisation affichée ---
|
||
function applyViz(vizKey) {{
|
||
const tiles = grid.querySelectorAll('.tile.has-data');
|
||
tiles.forEach(tile => {{
|
||
const col = parseInt(tile.dataset.col);
|
||
const row = parseInt(tile.dataset.row);
|
||
const data = tileMap[col + ',' + row];
|
||
if (!data) return;
|
||
const img = tile.querySelector('img');
|
||
let chosen = vizKey;
|
||
if (!data.viz[chosen]) chosen = data.display_viz;
|
||
if (!data.viz[chosen]) chosen = Object.keys(data.viz)[0];
|
||
const v = data.viz[chosen];
|
||
if (img) {{ img.src = v.thumb; }}
|
||
tile._fullSrc = v.full; // store for download
|
||
tile._vizKey = chosen;
|
||
}});
|
||
}}
|
||
|
||
// Load initial viz
|
||
applyViz(vizSelect.value);
|
||
vizSelect.addEventListener('change', () => applyViz(vizSelect.value));
|
||
|
||
// --- Zoom / Pan ---
|
||
let zoom = 1, panX = 0, panY = 0;
|
||
let isDragging = false, dragStartX = 0, dragStartY = 0, startPanX = 0, startPanY = 0;
|
||
let dragThreshold = 3; // px to distinguish click from drag
|
||
let dragDistance = 0;
|
||
|
||
function applyTransform() {{
|
||
grid.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`;
|
||
zoomInd.textContent = 'Zoom : ' + Math.round(zoom * 100) + '%';
|
||
}}
|
||
|
||
function clampPan() {{
|
||
const vpW = viewport.clientWidth;
|
||
const vpH = viewport.clientHeight;
|
||
const gw = gridCols * CELL_PX * zoom;
|
||
const gh = gridRows * CELL_PX * zoom;
|
||
panX = Math.min(vpW * 0.5, Math.max(vpW - gw - vpW * 0.5, panX));
|
||
panY = Math.min(vpH * 0.5, Math.max(vpH - gh - vpH * 0.5, panY));
|
||
if (gw < vpW) panX = (vpW - gw) / 2;
|
||
if (gh < vpH) panY = (vpH - gh) / 2;
|
||
}}
|
||
|
||
function resetView() {{
|
||
zoom = 1;
|
||
panX = (viewport.clientWidth - gridCols * CELL_PX) / 2;
|
||
panY = (viewport.clientHeight - gridRows * CELL_PX) / 2;
|
||
applyTransform();
|
||
}}
|
||
|
||
function zoomAt(factor, cx, cy) {{
|
||
const newZoom = Math.max(0.1, Math.min(50, zoom * factor));
|
||
const realFactor = newZoom / zoom;
|
||
panX = cx - (cx - panX) * realFactor;
|
||
panY = cy - (cy - panY) * realFactor;
|
||
zoom = newZoom;
|
||
clampPan();
|
||
applyTransform();
|
||
}}
|
||
|
||
viewport.addEventListener('wheel', (e) => {{
|
||
e.preventDefault();
|
||
const rect = viewport.getBoundingClientRect();
|
||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||
zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top);
|
||
}}, {{ passive: false }});
|
||
|
||
viewport.addEventListener('mousedown', (e) => {{
|
||
isDragging = true;
|
||
dragDistance = 0;
|
||
viewport.classList.add('dragging');
|
||
dragStartX = e.clientX;
|
||
dragStartY = e.clientY;
|
||
startPanX = panX;
|
||
startPanY = panY;
|
||
}});
|
||
|
||
window.addEventListener('mousemove', (e) => {{
|
||
if (!isDragging) return;
|
||
const dx = e.clientX - dragStartX;
|
||
const dy = e.clientY - dragStartY;
|
||
dragDistance = Math.sqrt(dx * dx + dy * dy);
|
||
panX = startPanX + dx;
|
||
panY = startPanY + dy;
|
||
clampPan();
|
||
applyTransform();
|
||
}});
|
||
|
||
window.addEventListener('mouseup', () => {{
|
||
isDragging = false;
|
||
viewport.classList.remove('dragging');
|
||
}});
|
||
|
||
viewport.addEventListener('dblclick', (e) => {{
|
||
const rect = viewport.getBoundingClientRect();
|
||
zoomAt(2, e.clientX - rect.left, e.clientY - rect.top);
|
||
}});
|
||
|
||
document.getElementById('zoomIn').addEventListener('click', () => {{
|
||
zoomAt(1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
|
||
}});
|
||
document.getElementById('zoomOut').addEventListener('click', () => {{
|
||
zoomAt(1 / 1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
|
||
}});
|
||
document.getElementById('zoomReset').addEventListener('click', resetView);
|
||
window.addEventListener('resize', () => {{ clampPan(); applyTransform(); }});
|
||
resetView();
|
||
|
||
// --- Modal avec légende ---
|
||
const modal = document.getElementById('modal');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
const modalClose = document.getElementById('modal-close');
|
||
const canvas = document.getElementById('legendCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
let currentTileData = null;
|
||
let currentVizKey = null;
|
||
|
||
function openModal(col, row) {{
|
||
const key = col + ',' + row;
|
||
const tile = tileMap[key];
|
||
if (!tile) return;
|
||
|
||
const vizKey = vizSelect.value;
|
||
let chosen = tile.viz[vizKey] ? vizKey : tile.display_viz;
|
||
if (!tile.viz[chosen]) chosen = Object.keys(tile.viz)[0];
|
||
|
||
currentTileData = tile;
|
||
currentVizKey = chosen;
|
||
|
||
const meta = VIZ_META[chosen] || {{ label: chosen, cmap: 'terrain', diverging: false }};
|
||
modalTitle.textContent = `${{meta.label}} — ${{tile.name}}`;
|
||
|
||
const v = tile.viz[chosen];
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => {{
|
||
drawWithLegend(img, chosen, tile, meta);
|
||
modal.classList.add('active');
|
||
}};
|
||
img.src = v.thumb;
|
||
}}
|
||
|
||
function closeModal() {{
|
||
modal.classList.remove('active');
|
||
}}
|
||
|
||
modalClose.addEventListener('click', closeModal);
|
||
modal.addEventListener('click', (e) => {{ if (e.target === modal) closeModal(); }});
|
||
document.addEventListener('keydown', (e) => {{ if (e.key === 'Escape') closeModal(); }});
|
||
|
||
// --- Dessin de la légende sur canvas ---
|
||
function drawWithLegend(img, vizKey, tile, meta) {{
|
||
// Canvas size: image width + legend bar
|
||
const imgW = img.width;
|
||
const imgH = img.height;
|
||
const legendW = 40;
|
||
const padding = 60;
|
||
const footerH = 80;
|
||
|
||
canvas.width = imgW + legendW + 40;
|
||
canvas.height = imgH + padding + footerH;
|
||
|
||
// Draw image
|
||
ctx.drawImage(img, 0, padding, imgW, imgH);
|
||
|
||
// Draw legend bar
|
||
const legendX = imgW + 20;
|
||
const grad = ctx.createLinearGradient(legendX, padding, legendX, padding + imgH);
|
||
|
||
// Generate gradient colors based on colormap
|
||
const colors = getColormapColors(meta.cmap, meta.diverging);
|
||
for (let i = 0; i < colors.length; i++) {{
|
||
const t = i / (colors.length - 1);
|
||
grad.addColorStop(t, colors[i]);
|
||
}}
|
||
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(legendX, padding, legendW, imgH);
|
||
ctx.strokeStyle = '#555';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(legendX, padding, legendW, imgH);
|
||
|
||
// Legend labels
|
||
ctx.fillStyle = '#e0e0e0';
|
||
ctx.font = '11px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Max', legendX + legendW + 6, padding + 12);
|
||
ctx.fillText('Min', legendX + legendW + 6, padding + imgH - 2);
|
||
|
||
// Title
|
||
ctx.fillStyle = '#e94560';
|
||
ctx.font = 'bold 14px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(meta.label, canvas.width / 2, 20);
|
||
|
||
// Footer info
|
||
ctx.fillStyle = '#a0a0c0';
|
||
ctx.font = '11px monospace';
|
||
ctx.textAlign = 'center';
|
||
const infoLine = `GPS: ${{tile.col}},${{tile.row}} (Lambert 93) | Résolution: ${{tile.resolution}}m/px | 1×1 km`;
|
||
ctx.fillText(infoLine, canvas.width / 2, canvas.height - 20);
|
||
}}
|
||
|
||
function getColormapColors(cmap, diverging) {{
|
||
const N = 64;
|
||
const colors = [];
|
||
for (let i = 0; i < N; i++) {{
|
||
const t = i / (N - 1);
|
||
colors.push(cmapColor(cmap, t, diverging));
|
||
}}
|
||
return colors;
|
||
}}
|
||
|
||
function cmapColor(cmap, t, diverging) {{
|
||
// Approximate matplotlib colormaps
|
||
const maps = {{
|
||
'seismic': (t) => {{
|
||
const c = diverging ? t : t;
|
||
if (c < 0.5) return lerpColor([100, 149, 237], [255, 255, 255], c * 2);
|
||
return lerpColor([255, 255, 255], [220, 50, 50], (c - 0.5) * 2);
|
||
}},
|
||
'gray': (t) => {{ const v = Math.round(t * 255); return [v, v, v]; }},
|
||
'inferno': (t) => {{
|
||
if (t < 0.25) return lerpColor([17, 100, 36], [117, 74, 14], t * 4);
|
||
if (t < 0.5) return lerpColor([117, 74, 14], [217, 112, 14], (t - 0.25) * 4);
|
||
if (t < 0.75) return lerpColor([217, 112, 14], [244, 206, 13], (t - 0.5) * 4);
|
||
return lerpColor([244, 206, 13], [255, 255, 255], (t - 0.75) * 4);
|
||
}},
|
||
'YlOrBr': (t) => {{
|
||
if (t < 0.33) return lerpColor([255, 255, 180], [255, 183, 77], t * 3);
|
||
if (t < 0.66) return lerpColor([255, 183, 77], [204, 123, 5], (t - 0.33) * 3);
|
||
return lerpColor([204, 123, 5], [103, 57, 0], (t - 0.66) * 3);
|
||
}},
|
||
'PuBu': (t) => {{
|
||
if (t < 0.5) return lerpColor([161, 211, 249], [60, 104, 180], t * 2);
|
||
return lerpColor([60, 104, 180], [33, 40, 102], (t - 0.5) * 2);
|
||
}},
|
||
'hot_r': (t) => {{ return lerpColor([0, 0, 0], [255, 255, 255], 1 - t); }},
|
||
'plasma': (t) => {{
|
||
if (t < 0.33) return lerpColor([13, 8, 92], [75, 14, 160], t * 3);
|
||
if (t < 0.66) return lerpColor([75, 14, 160], [150, 50, 80], (t - 0.33) * 3);
|
||
return lerpColor([150, 50, 80], [255, 200, 50], (t - 0.66) * 3);
|
||
}},
|
||
'cividis': (t) => {{
|
||
if (t < 0.5) return lerpColor([0, 0, 130], [20, 140, 160], t * 2);
|
||
return lerpColor([20, 140, 160], [255, 230, 50], (t - 0.5) * 2);
|
||
}},
|
||
'YlGn': (t) => {{
|
||
if (t < 0.5) return lerpColor([255, 255, 150], [150, 220, 100], t * 2);
|
||
return lerpColor([150, 220, 100], [0, 100, 0], (t - 0.5) * 2);
|
||
}},
|
||
'YlOrRd': (t) => {{
|
||
if (t < 0.33) return lerpColor([255, 255, 200], [255, 200, 100], t * 3);
|
||
if (t < 0.66) return lerpColor([255, 200, 100], [230, 100, 50], (t - 0.33) * 3);
|
||
return lerpColor([230, 100, 50], [150, 0, 0], (t - 0.66) * 3);
|
||
}},
|
||
'twilight': (t) => {{
|
||
const hue = (t * 360 + 180) % 360;
|
||
return hslToRgb(hue, 70, 50);
|
||
}},
|
||
'rgb': (t) => {{ return [100, 180, 100]; }},
|
||
}};
|
||
|
||
const fn = maps[cmap] || maps['gray'];
|
||
return fn(t);
|
||
}}
|
||
|
||
function lerpColor(a, b, t) {{
|
||
t = Math.max(0, Math.min(1, t));
|
||
return [
|
||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||
];
|
||
}}
|
||
|
||
function hslToRgb(h, s, l) {{
|
||
s /= 100; l /= 100;
|
||
const k = (n) => (n + h / 30) % 12;
|
||
const a = s * Math.min(l, 1 - l);
|
||
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
||
}}
|
||
|
||
// --- PDF download ---
|
||
document.getElementById('downloadPdf').addEventListener('click', () => {{
|
||
if (!currentTileData || !currentVizKey) return;
|
||
const {{ jsPDF }} = window.jspdf;
|
||
const orientation = canvas.width > canvas.height ? 'landscape' : 'portrait';
|
||
const pdf = new jsPDF(orientation, 'px', [canvas.width, canvas.height]);
|
||
pdf.addImage(canvas, 'PNG', 0, 0, canvas.width, canvas.height);
|
||
const vizKey = currentVizKey;
|
||
const name = currentTileData.name;
|
||
pdf.save(`${{name}}_${{vizKey}}.pdf`);
|
||
}});
|
||
|
||
document.getElementById('downloadFull').addEventListener('click', () => {{
|
||
if (!currentTileData || !currentVizKey) return;
|
||
const tile = grid.querySelector(`.tile[data-col="${{currentTileData.col}}"][data-row="${{currentTileData.row}}"]`);
|
||
if (tile && tile._fullSrc) {{
|
||
const a = document.createElement('a');
|
||
a.href = tile._fullSrc;
|
||
a.download = tile._fullSrc.split('/').pop();
|
||
a.click();
|
||
}}
|
||
}});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|
||
"""
|