Files
lidar_rendu/lidar_pipeline/index.py
2026-07-29 00:11:50 +02:00

925 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Carte continue interactive des tuiles LiDAR traitées.
Génère 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">&times;</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 (ignore if we dragged)
div.addEventListener('click', (e) => {{
if (dragDistance > dragThreshold) 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);
const c = colors[i];
grad.addColorStop(t, `rgb(${{c[0]}},${{c[1]}},${{c[2]}})`);
}}
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>
"""