Vue dézoomée en mosaïque d'overview et cache immutable des images versionnées

This commit is contained in:
Antoine Jacquin
2026-09-20 00:26:18 +02:00
parent 3236d56a4b
commit 342a0d2213
5 changed files with 563 additions and 50 deletions

View File

@ -305,6 +305,18 @@ _SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'})
# complet dès qu'une tuile dépasse ~300 px à l'écran.
_MID_THUMB_SIZE = 640
# Mosaïque d'overview (vue dézoomée) : une image WebP par couche couvrant
# toute la grille, assemblée depuis les vignettes des dalles. Sous un seuil
# de zoom l'interface l'affiche à la place des vignettes individuelles :
# 1 requête et ~30 Mo décodés par couche au lieu de centaines. La résolution
# est plafonnée : l'overview n'est affiché que tant qu'il reste au moins 1:1
# avec l'écran (jamais flou).
_OVERVIEW_DIR = 'index_overview'
_OVERVIEW_MIN_DALLES = 6 # grille plus petite : les vignettes suffisent
_OVERVIEW_MAX_PX = 3072 # côté max de la mosaïque (~28 Mo décodés)
_OVERVIEW_PX_PER_KM = 256 # résolution cible (1:1 avec les vignettes)
_OVERVIEW_QUALITY = 80
# Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli.
_VIZ_FALLBACK_ORDER = [
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
@ -622,13 +634,13 @@ def _cached_file_fresh(path, src_mtime):
def _url_version(mtime):
"""Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu.
Les images de la carte sont servies par des montages statiques sans
Cache-Control : après une régénération, le cache heuristique du navigateur
peut continuer d'afficher l'ancien rendu (même URL = image considérée
fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un
recalcul change l'URL et force le rechargement — y compris en direct
pendant un run : pollLiveTiles fusionne les nouvelles données et
updateImages() voit un src différent.
Les images de la carte sont servies avec un cache immutable quand l'URL
porte ?v= : le suffixe DOIT donc identifier le contenu du fichier servi,
pas celui de sa source (une vignette recalculée après coup, ou un cache
webapp rapatrié, change de contenu sans que la source bouge). Chaque URL
est versionnée par la mtime de SON fichier. Un recalcul change l'URL et
force le rechargement — y compris en direct pendant un run : pollLiveTiles
fusionne les nouvelles données et updateImages() voit un src différent.
"""
return f"?v={int(mtime * 1000)}" if mtime is not None else ""
@ -876,12 +888,18 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
if cut_failed:
_fallback_full_dalle(entries, viz_key, info)
continue
v = _url_version(src_mtime)
# Chaque URL est versionnée par la mtime de SON fichier (cf.
# _url_version) : le chemin court « vignettes seules recalculées »
# ci-dessus ne change pas les URLs des AVIF/mid non réécrits —
# le cache immutable du navigateur reste valable pour eux.
def _file_v(name):
return _url_version(_mtime(output_dir / sub_dir_name / name))
for (i, j), stem in stems.items():
entries[(i, j)]['viz'][viz_key] = {
'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{v}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
'full': f"{sub_dir_name}/{stem}.avif{v}",
'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{_file_v(stem + thumb_suffix)}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{_file_v(stem + '_mid.webp')}",
'full': f"{sub_dir_name}/{stem}.avif{_file_v(stem + '.avif')}",
}
except Exception as e:
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
@ -899,6 +917,108 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
return usable
def _build_overview(dalles, output_dir):
"""Assemble la mosaïque d'overview par couche pour la vue dézoomée.
Une image WebP unique couvre toute la grille L93 des dalles (les cases
sans dalle restent transparentes), assemblée depuis les vignettes
index_thumbs (256 px/km) : au dézoom l'interface l'affiche à la place des
vignettes individuelles — 1 requête et ~30 Mo décodés par couche au lieu
de centaines. Résolution plafonnée à 256 px/km et 3072 px de côté :
l'overview n'est affiché que tant qu'il reste au moins 1:1 avec l'écran,
jamais étiré donc jamais flou.
Args:
dalles: enregistrements de dalles (zone_tile_records) avec col/row,
corners WGS84 et viz[k]['thumb'] (URL relative à output_dir).
output_dir: dossier de sortie racine.
Returns:
{viz_key: {url, w, h, px_per_km, km_w, km_h, corners}} ou {} si
grille trop petite, coins manquants ou PIL indisponible.
"""
if len(dalles) < _OVERVIEW_MIN_DALLES:
return {}
try:
from PIL import Image as PILImage
except ImportError:
logger.warning("PIL indisponible — overview non générée")
return {}
have = [t for t in dalles if t.get('corners')]
if not have:
return {}
min_col = min(t['col'] for t in have)
max_col = max(t['col'] for t in have)
min_row = min(t['row'] for t in have)
max_row = max(t['row'] for t in have)
# Lattice exact des coins (km L93 → WGS84) : les coins partagés de dalles
# voisines proviennent de la même transformation pyproj → cohérents, la
# mosaïque s'emboîte exactement comme les vignettes individuelles.
lattice = {}
for t in have:
c = t['corners']
lattice[(t['col'], t['row'])] = c[0] # SW
lattice[(t['col'] + 1, t['row'])] = c[1] # SE
lattice[(t['col'] + 1, t['row'] + 1)] = c[2] # NE
lattice[(t['col'], t['row'] + 1)] = c[3] # NW
quad = [(min_col, min_row), (max_col + 1, min_row),
(max_col + 1, max_row + 1), (min_col, max_row + 1)]
if any(k not in lattice for k in quad):
return {}
km_w = max_col + 1 - min_col
km_h = max_row + 1 - min_row
px_per_km = max(4, min(_OVERVIEW_PX_PER_KM,
_OVERVIEW_MAX_PX // max(km_w, km_h)))
w, h = km_w * px_per_km, km_h * px_per_km
out_dir = output_dir / _OVERVIEW_DIR
out_dir.mkdir(parents=True, exist_ok=True)
try:
resample = PILImage.Resampling.LANCZOS
except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
viz_keys = []
for t in have:
for k in t['viz']:
if k not in viz_keys:
viz_keys.append(k)
overview = {}
for viz_key in viz_keys:
canvas = PILImage.new('RGBA', (w, h), (0, 0, 0, 0))
pasted = 0
for t in have:
info = t['viz'].get(viz_key)
if not info:
continue
src = output_dir / info['thumb'].split('?')[0]
try:
with PILImage.open(str(src)) as im:
cell = im.convert('RGBA')
except Exception as e:
logger.debug(f"Overview : vignette illisible {src.name} : {e}")
continue
if cell.size != (px_per_km, px_per_km):
cell = cell.resize((px_per_km, px_per_km), resample)
# Image ligne 0 = nord : la dalle de row max est en haut.
canvas.paste(cell, ((t['col'] - min_col) * px_per_km,
(max_row - t['row']) * px_per_km))
pasted += 1
if not pasted:
continue
out = out_dir / f"{viz_key}.webp"
try:
canvas.save(str(out), format='WEBP', quality=_OVERVIEW_QUALITY)
except Exception as e:
logger.warning(f"Overview {viz_key} non écrite : {e}")
continue
overview[viz_key] = {
'url': f"{_OVERVIEW_DIR}/{out.name}{_url_version(_mtime(out))}",
'w': w, 'h': h, 'px_per_km': px_per_km,
'km_w': km_w, 'km_h': km_h,
'corners': [lattice[k] for k in quad],
}
return overview
def _viz_src_dir(tile, info):
"""Dossier réel du fichier d'une visualisation.
@ -1068,13 +1188,18 @@ def build_index(output_dir, output_format='avif'):
else:
thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name
v = _url_version(src_mtime)
# Chaque URL versionnée par la mtime de SON fichier (cf.
# _url_version) : cache navigateur immutable possible.
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}{v}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}",
'thumb': f"index_thumbs/{thumb_name}"
f"{_url_version(_mtime(thumb_path))}",
# La dalle source EST le fichier servi : sa mtime suffit.
'full': f"visualisations/{viz_dir_name}/{info['filename']}"
f"{_url_version(src_mtime)}",
}
if mid_path.is_file():
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}{v}"
viz_thumbs[viz_key]['mid'] = (
f"index_thumbs/{mid_name}{_url_version(_mtime(mid_path))}")
if regen:
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
@ -1131,12 +1256,22 @@ def build_index(output_dir, output_format='avif'):
if n_sous:
logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)")
# Mosaïque d'overview (vue dézoomée) : une image par couche couvrant
# toute la grille, assemblée depuis les vignettes des dalles.
dalle_records = [t for zr in zone_records for t in zr['tiles']]
overview = _build_overview(dalle_records, output_dir)
if overview:
first = next(iter(overview.values()))
logger.info(f" Overview : {len(overview)} couche(s) "
f"({first['w']}×{first['h']} px, {first['px_per_km']} px/km)")
# Export des données de carte (index_tiles.json) : la webapp les sert via
# /api/tiles pour afficher les tuiles terminées en direct, sans recharger
# la page — le pipeline le réécrit après chaque tuile en mode incrémental.
ordered_viz, default_viz = _panel_selection(all_viz_keys)
(output_dir / 'index_tiles.json').write_text(json.dumps({
'tiles': display_tiles,
'overview': overview,
'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz},
'stats': {'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys),
@ -1146,7 +1281,8 @@ def build_index(output_dir, output_format='avif'):
}, ensure_ascii=False), encoding='utf-8')
_write_assets(output_dir)
html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format)
html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format,
overview)
html_path = output_dir / 'index.html'
html_path.write_text(html, encoding='utf-8')
@ -1222,11 +1358,12 @@ def _json_for_script(obj):
return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
def _render_html(tiles, global_bbox, all_viz_keys, output_format, overview=None):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
ordered_viz, default_viz = _panel_selection(all_viz_keys)
tiles_json = _json_for_script(tiles)
overview_json = _json_for_script(overview or {})
# Les assets sont référencés avec une version (hash de leur contenu) :
# une URL neuve à chaque évolution du CSS/JS force le rechargement chez
@ -1268,6 +1405,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
return _HTML_TEMPLATE.format(
tiles_json=tiles_json,
overview_json=overview_json,
viz_meta_json=viz_meta_json,
stats_json=stats_json,
assets_v=assets_v,
@ -1466,6 +1604,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<script>
const TILES = {tiles_json};
const OVERVIEW = {overview_json};
const VIZ_META = {viz_meta_json};
const STATS = {stats_json};
</script>
@ -1715,6 +1854,14 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; }
}
.ltile:hover { outline-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); }
.ltile.selected { outline-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); }
/* Overview (mosaïque unique de la vue dézoomée) : pas de contour ni de
survol — il couvre toute la grille —, hors content-visibility (l'ancre
est un coin de grille souvent hors écran alors que la mosaïque couvre
l'écran : le navigateur pourrait sauter sa peinture) et l'échange de
palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle
affichée : jamais de trou ni de flash au dézoom. */
.ltile.loverview { outline: none; pointer-events: none; content-visibility: visible; }
.ltile img.fading { transition: opacity 300ms ease; opacity: 0 !important; }
/* --- Fiche tuile (onglet Tuile) --- */
.tile-info { padding: 6px 6px 10px; }
@ -2200,6 +2347,139 @@ function recomputeGridBounds() {
for (const t of TILES) registerTile(t);
recomputeGridBounds();
// ============================================================
// Overview : 1 image par couche pour la vue dézoomée
// ============================================================
// La mosaïque (index_overview) couvre toute la grille ; tant qu'un kilomètre
// affiché reste plus petit que sa résolution (px_per_km), elle remplace
// toutes les vignettes : 1 requête et ~30 Mo décodés par couche au lieu de
// centaines. La bascule ne se joue qu'à zoomend/moveend — jamais pendant
// l'animation, le pane met alors le palier sortant à l'échelle d'un bloc —
// et sans trou : le gagnant se pré-décode (setImgSrc), et le perdant ne
// s'efface (fondu 300 ms) puis ne libère son src qu'une fois le gagnant
// affiché.
let ovEntry = null; // marqueur overview (même structure qu'une tuile)
let ovOn = false; // palier courant = overview ?
let ovLoser = null; // bascule en cours : {imgs, winners, since, phase}
const OV_HOLD_POLL_MS = 120; // sondage « gagnant affiché ? »
const OV_FADE_MS = 340; // fondu du perdant + marge de libération
const OV_HOLD_CAP_MS = 2500; // délai maxi avant de libérer quand même
const ovMeta = () => { for (const k in OVERVIEW) return OVERVIEW[k]; return null; };
function registerOverview() {
const meta = ovMeta();
if (!meta || ovEntry || !map) return;
// Ancre = coin NW de la grille : la matrice mappe l'image depuis (0,0).
const nw = meta.corners[3];
const icon = L.divIcon({ className: 'ltile-anchor',
html: '<div class="ltile loverview"></div>',
iconSize: [0, 0] });
const mk = L.marker(L.latLng(nw[0], nw[1]), {
icon: icon, interactive: false, keyboard: false,
pane: 'lidarTiles', zIndexOffset: -100000
}).addTo(map);
const el = mk.getElement();
ovEntry = {
mk: mk, div: el ? el.querySelector('.ltile') : null, imgs: {},
latlngs: meta.corners.map(c => L.latLng(c[0], c[1])),
bounds: L.latLngBounds(meta.corners)
};
}
// Homographie 4 correspondances → matrix3d CSS (colonne-major), ou null.
// La grille L93 n'est pas un parallélogramme exact en Mercator à l'échelle
// de la zone : la projection exacte des 4 coins fait tomber la mosaïque sur
// ceux des dalles (emboîtement parfait avec les vignettes).
function quadMatrix3d(src, dst) {
const A = [], b = [];
for (let i = 0; i < 4; i++) {
const x = src[i][0], y = src[i][1], u = dst[i][0], v = dst[i][1];
A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]); b.push(u);
A.push([0, 0, 0, x, y, 1, -v * x, -v * y]); b.push(v);
}
const n = 8;
for (let c = 0; c < n; c++) {
let piv = c;
for (let r = c + 1; r < n; r++)
if (Math.abs(A[r][c]) > Math.abs(A[piv][c])) piv = r;
if (Math.abs(A[piv][c]) < 1e-9) return null;
const tA = A[c]; A[c] = A[piv]; A[piv] = tA;
const tb = b[c]; b[c] = b[piv]; b[piv] = tb;
for (let r = 0; r < n; r++) {
if (r === c) continue;
const f = A[r][c] / A[c][c];
for (let k = c; k < n; k++) A[r][k] -= f * A[c][k];
b[r] -= f * b[c];
}
}
const h = [];
for (let i = 0; i < n; i++) h.push(b[i] / A[i][i]);
const h11 = h[0], h21 = h[1], h31 = h[2], h12 = h[3], h22 = h[4],
h32 = h[5], h13 = h[6], h23 = h[7];
return [h11, h12, 0, h13, h21, h22, 0, h23, 0, 0, 1, 0, h31, h32, 0, 1];
}
function layoutOverview() {
if (!ovEntry || !ovEntry.div) return;
const meta = ovMeta();
if (!meta) return;
const zoom = map.getZoom();
const anchor = map.project(ovEntry.latlngs[3], zoom);
// Coins image : NW(0,0) NE(w,0) SE(w,h) SW(0,h) — soit corners[3,2,1,0].
const dst = [3, 2, 1, 0].map(i => {
const p = map.project(ovEntry.latlngs[i], zoom);
return [p.x - anchor.x, p.y - anchor.y];
});
const m = quadMatrix3d([[0, 0], [meta.w, 0], [meta.w, meta.h], [0, meta.h]], dst);
const div = ovEntry.div;
div.style.width = meta.w + 'px';
div.style.height = meta.h + 'px';
div.style.transformOrigin = '0 0';
div.style.transform = m
? 'matrix3d(' + m.join(',') + ')'
: 'translate(-9999px, -9999px)'; // homographie insoluble : hors écran
}
// Overview actif tant qu'un kilomètre à l'écran ne dépasse pas la résolution
// de la mosaïque (affichée au pire à 1:1, jamais étirée donc jamais floue).
function overviewWanted() {
const meta = ovMeta();
if (!meta || !map._loaded) return false;
const zoom = map.getZoom();
const sw = map.project(meta.corners[0], zoom), se = map.project(meta.corners[1], zoom);
return Math.hypot(se.x - sw.x, se.y - sw.y) / meta.km_w <= meta.px_per_km;
}
// Libération du palier perdant : seulement quand le gagnant est affiché
// (img.complete), au pire après OV_HOLD_CAP_MS — jamais de trou blanc.
function ovTick() {
if (!ovLoser) return;
const now = performance.now();
if (ovLoser.phase === 'hold') {
const ready = !ovLoser.winners.length ||
ovLoser.winners.every(i => i.complete);
if (ready || now - ovLoser.since > OV_HOLD_CAP_MS) {
ovLoser.phase = 'fade';
for (const img of ovLoser.imgs) img.classList.add('fading');
ovLoser.timer = setTimeout(ovTick, OV_FADE_MS);
return;
}
ovLoser.timer = setTimeout(ovTick, OV_HOLD_POLL_MS);
return;
}
// fade terminé : on libère le src (et la mémoire décodée)
for (const img of ovLoser.imgs) {
img.classList.remove('fading');
delete img.dataset.loser;
img.dataset.want = '';
img.removeAttribute('src');
}
ovLoser = null;
}
registerOverview();
function entryDiv(entry) {
if (!entry.div) {
const el = entry.mk.getElement();
@ -2250,6 +2530,7 @@ function layoutTiles() {
entry.proj = { w: w, h: h };
entry.projZoom = zoom;
}
layoutOverview();
// Resynchronise aussi les <img> : au premier rendu les marqueurs viennent
// tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés.
syncTileLayers();
@ -2280,6 +2561,32 @@ function syncTileLayers() {
div.appendChild(img);
entry.imgs[k] = img;
}
img.classList.remove('fading');
img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]);
img.style.mixBlendMode = state.layers.blend[k] || 'normal';
});
}
// Overview : mêmes couches actives, une <img> par couche disponible.
if (ovEntry && ovEntry.div) {
for (const k of Object.keys(ovEntry.imgs)) {
if (act.includes(k) && OVERVIEW[k]) continue;
ovEntry.imgs[k].src = '';
ovEntry.imgs[k].remove();
delete ovEntry.imgs[k];
}
act.forEach((k, i) => {
if (!OVERVIEW[k]) return;
let img = ovEntry.imgs[k];
if (!img) {
img = document.createElement('img');
img.dataset.layer = k;
img.alt = '';
img.decoding = 'async';
ovEntry.div.appendChild(img);
ovEntry.imgs[k] = img;
}
img.classList.remove('fading');
img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]);
img.style.mixBlendMode = state.layers.blend[k] || 'normal';
@ -2338,6 +2645,19 @@ function updateImages() {
map.unproject(L.point(neP.x + margin, neP.y - margin), zoom));
const visBounds = expand(PRELOAD_MARGIN_PX);
const midBounds = expand(MID_MARGIN_PX);
// Bascule de palier overview ↔ vignettes (uniquement ici : zoomend/moveend,
// jamais pendant l'animation de zoom). À la bascule le perdant garde son
// src (ovTick le libère une fois le gagnant affiché) : pas de trou.
const wantOv = ovEntry ? overviewWanted() : false;
if (wantOv !== ovOn) {
if (ovLoser) {
clearTimeout(ovLoser.timer);
for (const img of ovLoser.imgs) { img.classList.remove('fading'); delete img.dataset.loser; }
}
ovOn = wantOv;
ovLoser = { imgs: [], winners: [], since: performance.now(),
phase: 'hold', timer: 0 };
}
// Candidats pleine résolution : triés du plus proche au plus lointain du
// centre, seuls les FULL_CAP premiers l'obtiennent.
const fullWant = [];
@ -2352,7 +2672,20 @@ function updateImages() {
for (const k in entry.imgs) {
const img = entry.imgs[k];
const v = entry.t.viz[k];
if (!visible || !v) { plan.push([img, null]); continue; }
if (!visible || !v || ovOn) {
// Overview actif : les vignettes visibles deviennent perdantes
// (libérées après affichage du gagnant), les autres se déchargent.
if (ovOn && visible && img.getAttribute('src') !== null) {
img.dataset.want = '';
if (ovLoser && !img.dataset.loser) {
img.dataset.loser = '1';
ovLoser.imgs.push(img);
}
continue;
}
plan.push([img, null]);
continue;
}
let want = v.thumb;
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
if (big && state.layers.opacity[k] > 0.15) {
@ -2368,6 +2701,23 @@ function updateImages() {
plan.push([img, want]);
}
}
// Overview : chargée quand son palier est actif, perdante sinon.
if (ovEntry) {
for (const k in ovEntry.imgs) {
const img = ovEntry.imgs[k];
if (ovOn && OVERVIEW[k]) {
plan.push([img, OVERVIEW[k].url]);
} else if (img.getAttribute('src') !== null) {
img.dataset.want = '';
if (ovLoser && !img.dataset.loser) {
img.dataset.loser = '1';
ovLoser.imgs.push(img);
}
} else {
plan.push([img, null]);
}
}
}
fullWant.sort((a, b) => a.dist - b.dist);
fullWant.forEach((c, i) => plan.push(
[c.img, i < FULL_CAP ? c.v.full : (c.v.mid || c.v.thumb)]));
@ -2377,9 +2727,18 @@ function updateImages() {
img.dataset.want = '';
if (img.getAttribute('src') !== null) img.removeAttribute('src');
} else {
img.classList.remove('fading');
setImgSrc(img, want);
}
}
if (ovLoser) {
if (!ovLoser.imgs.length) { ovLoser = null; return; }
// Gagnants de CE tour : tout <img> qui vient de recevoir un src. Le
// perdant ne s'efface qu'une fois ceux-là affichés (ovTick).
ovLoser.winners = plan.filter(p => p[1]).map(p => p[0]);
if (!ovLoser.timer)
ovLoser.timer = setTimeout(ovTick, OV_HOLD_POLL_MS);
}
}
// ============================================================
@ -3919,6 +4278,21 @@ function mergeTilesData(data) {
metaChanged = true;
}
}
// Overview neuve (fin de rebuild pendant un run) : mosaïque recalculée →
// nouvelle URL ?v= → l'<img> de la couche se recharge.
let ovChanged = false;
if (data.overview) {
for (const k of Object.keys(data.overview)) {
const cur = OVERVIEW[k];
if (cur && cur.url === data.overview[k].url) continue;
OVERVIEW[k] = data.overview[k];
ovChanged = true;
}
if (ovChanged && !ovEntry) {
registerOverview();
layoutOverview();
}
}
if (changed) {
recomputeGridBounds();
const statTiles = document.getElementById('statTiles');
@ -3932,7 +4306,7 @@ function mergeTilesData(data) {
}
}
if (changed || metaChanged) renderLayerPanel();
if (changed) {
if (changed || ovChanged) {
syncTileLayers(); layoutTiles();
// Une dalle qui vient d'apparaître prend le relais du polygone de
// sélection (surlignage du marqueur désormais possible)