@ -1,8 +1,9 @@
""" Carte global e interactive des tuiles LiDAR traitées.
""" Carte continu e 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 interactiv e HTML des tuiles traitées.
""" Génère la carte continu e 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); col or: #e0e0e0; font-size: 9px;
padding: 2px 4px; text-align: cen ter ; 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: absolu te; 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 global e LiDAR</h1>
<h1>Carte continu e 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 cell s = grid.querySelectorAll( ' .cell .has-tile ' );
cell s.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 tile s = grid.querySelectorAll( ' .tile .has-data ' );
tile s.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(2 0, zoom * factor));
const newZoom = Math.max(0.1 , Math.min(5 0, 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>