Vue dézoomée en mosaïque d'overview et cache immutable des images versionnées
This commit is contained in:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user