carte continue: cropped images + continuous map + PDF download via jsPDF
This commit is contained in:
@ -1,8 +1,9 @@
|
||||
"""Carte globale interactive des tuiles LiDAR traitées.
|
||||
"""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, positionnées sur une grille Lambert 93, avec zoom/pan natifs
|
||||
et sélecteur de visualisation. Permet de choisir rapidement la tuile à examiner.
|
||||
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
|
||||
@ -42,6 +43,27 @@ VIZ_LABELS = {
|
||||
'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'
|
||||
|
||||
@ -225,7 +247,7 @@ def _pick_display_viz(viz_keys):
|
||||
|
||||
|
||||
def build_index(output_dir, output_format='avif'):
|
||||
"""Génère la carte interactive HTML des tuiles traitées.
|
||||
"""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/.
|
||||
@ -312,7 +334,7 @@ def build_index(output_dir, output_format='avif'):
|
||||
|
||||
|
||||
def _render_html(tile_records, bbox, all_viz_keys, output_format):
|
||||
"""Construit le HTML complet avec CSS et JS natifs (zoom/pan)."""
|
||||
"""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):
|
||||
@ -325,6 +347,12 @@ def _render_html(tile_records, bbox, all_viz_keys, output_format):
|
||||
'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>'
|
||||
@ -337,6 +365,7 @@ def _render_html(tile_records, bbox, all_viz_keys, output_format):
|
||||
|
||||
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,
|
||||
@ -350,10 +379,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Carte globale des tuiles LiDAR</title>
|
||||
<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: #1a1a2e; color: #e0e0e0; overflow: hidden; }}
|
||||
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;
|
||||
@ -383,31 +412,32 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
|
||||
#viewport {{
|
||||
position: absolute; top: 56px; left: 0; right: 0; bottom: 0;
|
||||
overflow: hidden; cursor: grab; background: #0d0d1a;
|
||||
overflow: hidden; cursor: grab; background: #0a0a14;
|
||||
}}
|
||||
#viewport.dragging {{ cursor: grabbing; }}
|
||||
#viewport.click-tile {{ cursor: pointer; }}
|
||||
|
||||
#grid {{
|
||||
position: absolute; transform-origin: 0 0;
|
||||
/* Dimensions fixées en JS selon le nombre de cellules */
|
||||
}}
|
||||
|
||||
.cell {{
|
||||
position: absolute; border: 1px solid #2a2a4a; background: #111122;
|
||||
overflow: hidden; display: block; text-decoration: none;
|
||||
.tile {{
|
||||
position: absolute;
|
||||
width: {grid_w}000px; height: {grid_h}000px;
|
||||
overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #111122;
|
||||
}}
|
||||
.cell.has-tile {{ border-color: #0f3460; }}
|
||||
.cell.has-tile:hover {{ border-color: #e94560; box-shadow: 0 0 12px rgba(233,69,96,0.5); z-index: 10; }}
|
||||
.cell img {{ width: 100%; height: 100%; object-fit: cover; display: block; }}
|
||||
.cell .label {{
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: rgba(0,0,0,0.65); color: #e0e0e0; font-size: 9px;
|
||||
padding: 2px 4px; text-align: center; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; opacity: 0; transition: opacity 0.15s;
|
||||
.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;
|
||||
}}
|
||||
.cell:hover .label {{ opacity: 1; }}
|
||||
.cell.empty {{ display: flex; align-items: center; justify-content: center; opacity: 0.3; }}
|
||||
.cell.empty .coords {{ font-size: 10px; color: #444466; }}
|
||||
|
||||
#zoomIndicator {{
|
||||
position: fixed; bottom: 12px; left: 12px; z-index: 100;
|
||||
@ -420,12 +450,59 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
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 globale LiDAR</h1>
|
||||
<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">
|
||||
@ -446,14 +523,32 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<div id="zoomIndicator">Zoom : 100%</div>
|
||||
<div id="hint">
|
||||
Molette : zoom • Clic-glisser : déplacer<br>
|
||||
Double-clic : zoom rapide • Clic tuile : ouvrir l'image
|
||||
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 CELL_PX = 200; // taille d'une cellule 1×1 km (en px, à zoom 1)
|
||||
const GAP_PX = 2;
|
||||
|
||||
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;
|
||||
@ -463,6 +558,7 @@ 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';
|
||||
|
||||
@ -472,7 +568,7 @@ for (const t of DATA.tiles) {{
|
||||
tileMap[t.col + ',' + t.row] = t;
|
||||
}}
|
||||
|
||||
// --- Construction de la grille (toutes les cellules, y compris vides) ---
|
||||
// --- 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)
|
||||
|
||||
@ -481,64 +577,65 @@ 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 cell = document.createElement('a');
|
||||
cell.className = 'cell' + (tile ? ' has-tile' : ' empty');
|
||||
cell.style.left = colToX(c) + 'px';
|
||||
cell.style.top = rowToY(r) + 'px';
|
||||
cell.style.width = (CELL_PX - GAP_PX) + 'px';
|
||||
cell.style.height = (CELL_PX - GAP_PX) + 'px';
|
||||
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) {{
|
||||
cell.target = '_blank';
|
||||
cell.title = tile.name;
|
||||
const img = document.createElement('img');
|
||||
div.title = tile.name;
|
||||
const img = document.createElement('img';
|
||||
img.loading = 'lazy';
|
||||
cell.appendChild(img);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.textContent = tile.name;
|
||||
cell.appendChild(label);
|
||||
}} else {{
|
||||
const coords = document.createElement('div');
|
||||
coords.className = 'coords';
|
||||
coords.textContent = c + '\\n' + r;
|
||||
coords.style.whiteSpace = 'pre';
|
||||
cell.appendChild(coords);
|
||||
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(cell);
|
||||
fragment.appendChild(div);
|
||||
}}
|
||||
}}
|
||||
grid.appendChild(fragment);
|
||||
|
||||
// --- Gestion de la visualisation affichée ---
|
||||
function applyViz(vizKey) {{
|
||||
const cells = grid.querySelectorAll('.cell.has-tile');
|
||||
cells.forEach(cell => {{
|
||||
// Retrouve la tuile via la position
|
||||
const left = parseFloat(cell.style.left);
|
||||
const top = parseFloat(cell.style.top);
|
||||
const col = bbox.min_col + Math.round(left / CELL_PX);
|
||||
const row = bbox.max_row - Math.round(top / CELL_PX);
|
||||
const tile = tileMap[col + ',' + row];
|
||||
if (!tile) return;
|
||||
const img = cell.querySelector('img');
|
||||
// Viz demandée, sinon display_viz, sinon première dispo
|
||||
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 (!tile.viz[chosen]) chosen = tile.display_viz;
|
||||
if (!tile.viz[chosen]) chosen = Object.keys(tile.viz)[0];
|
||||
const v = tile.viz[chosen];
|
||||
if (img) {{
|
||||
img.src = v.thumb;
|
||||
}}
|
||||
cell.href = v.full;
|
||||
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}})`;
|
||||
@ -550,10 +647,8 @@ function clampPan() {{
|
||||
const vpH = viewport.clientHeight;
|
||||
const gw = gridCols * CELL_PX * zoom;
|
||||
const gh = gridRows * CELL_PX * zoom;
|
||||
// Permet un léger débordement pour ne pas bloquer le déplacement
|
||||
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));
|
||||
// Si la grille est plus petite que le viewport, centre
|
||||
if (gw < vpW) panX = (vpW - gw) / 2;
|
||||
if (gh < vpH) panY = (vpH - gh) / 2;
|
||||
}}
|
||||
@ -566,9 +661,8 @@ function resetView() {{
|
||||
}}
|
||||
|
||||
function zoomAt(factor, cx, cy) {{
|
||||
const newZoom = Math.max(0.2, Math.min(20, zoom * factor));
|
||||
const newZoom = Math.max(0.1, Math.min(50, zoom * factor));
|
||||
const realFactor = newZoom / zoom;
|
||||
// Garde le point (cx,cy) fixe dans la grille
|
||||
panX = cx - (cx - panX) * realFactor;
|
||||
panY = cy - (cy - panY) * realFactor;
|
||||
zoom = newZoom;
|
||||
@ -584,8 +678,8 @@ viewport.addEventListener('wheel', (e) => {{
|
||||
}}, {{ passive: false }});
|
||||
|
||||
viewport.addEventListener('mousedown', (e) => {{
|
||||
if (e.target.closest('a.has-tile')) return; // ne déplace pas si clic sur tuile
|
||||
isDragging = true;
|
||||
dragDistance = 0;
|
||||
viewport.classList.add('dragging');
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
@ -595,8 +689,11 @@ viewport.addEventListener('mousedown', (e) => {{
|
||||
|
||||
window.addEventListener('mousemove', (e) => {{
|
||||
if (!isDragging) return;
|
||||
panX = startPanX + (e.clientX - dragStartX);
|
||||
panY = startPanY + (e.clientY - dragStartY);
|
||||
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();
|
||||
}});
|
||||
@ -607,7 +704,6 @@ window.addEventListener('mouseup', () => {{
|
||||
}});
|
||||
|
||||
viewport.addEventListener('dblclick', (e) => {{
|
||||
if (e.target.closest('a.has-tile')) return;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
zoomAt(2, e.clientX - rect.left, e.clientY - rect.top);
|
||||
}});
|
||||
@ -619,12 +715,207 @@ document.getElementById('zoomOut').addEventListener('click', () => {{
|
||||
zoomAt(1 / 1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
|
||||
}});
|
||||
document.getElementById('zoomReset').addEventListener('click', resetView);
|
||||
|
||||
// Recalcule au redimensionnement de la fenêtre
|
||||
window.addEventListener('resize', () => {{ clampPan(); applyTransform(); }});
|
||||
|
||||
// Vue initiale centrée
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user