Alléger la carte : rugosité retirée, vignettes 160 px, cap mémoire

Retire la couche Rugosité du panneau (DEFAULT_LAYERS restreint panneau,
presets et sélecteur de génération). Réduit la mémoire du navigateur :
vignettes de sous-tuiles à 160 px (régénérées sans ré-encoder les AVIF),
pleine résolution plafonnée à 24 images les plus proches du centre, src
des <img> libéré hors écran. Les changements de palier pré-décodent
l'image avant échange (zoom et pan sans flash).
This commit is contained in:
Antoine Jacquin
2026-09-16 21:23:24 +02:00
parent 8d29f03191
commit daed457adb
2 changed files with 137 additions and 45 deletions

View File

@ -201,7 +201,7 @@ VIZ_LEGENDS = {
# Couches activées par défaut à l'ouverture de la carte : seules celles-ci
# sont allumées, toutes les autres sont désactivées (mais restent accessibles
# via le panneau). Ordre = pile basse → haute.
DEFAULT_LAYERS = ('slope', 'aspect', 'positive_openness', 'roughness')
DEFAULT_LAYERS = ('slope', 'aspect', 'positive_openness')
# Couche « principale » (mise en avant ; index_tiles.json + affichage en
# direct de la webapp).
@ -273,6 +273,12 @@ _CARTO_SUBTILED_VIZ = ()
_SUBTILE_AVIF_QUALITY = 55
_SUBTILE_AVIF_SPEED = 8
# Vignette de sous-tuile (px) : à la vue d'ensemble chaque sous-tuile active
# décode sa vignette en navigateur — 160 px couvre l'affichage jusqu'à ~220 px
# à l'écran et réduit la mémoire décodée d'un facteur ~2,5 vs 256 px. La taille
# est encodée dans le nom de fichier : la changer invalide le cache.
_SUBTILE_THUMB_PX = 160
# Couches à contenu photographique ou traits fins (orthophoto, carte topo) :
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
# relevée pour ces couches uniquement.
@ -751,6 +757,18 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
try:
resample = getattr(PILImage, 'LANCZOS', 1)
thumb_suffix = f"_thumb{_SUBTILE_THUMB_PX}.webp"
def _save_thumb(quad, stem):
"""Écrit la vignette de sous-tuile (redimensionnée à _SUBTILE_THUMB_PX)."""
scale = min(1.0, _SUBTILE_THUMB_PX / max(quad.size))
out = quad
if scale < 1.0:
out = quad.resize((max(1, int(quad.size[0] * scale)),
max(1, int(quad.size[1] * scale))), resample)
out.save(str(output_dir / sub_dir_name / (stem + thumb_suffix)),
format='WEBP', quality=80)
for viz_key in offered_viz_keys:
info = tile['viz'].get(viz_key)
if not info:
@ -765,12 +783,33 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
src = output_dir / info['full'].split('?')[0]
src_mtime = _mtime(src)
all_exist = all(
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime)
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_mid.webp'), src_mtime)
for stem in stems.values())
if not all_exist:
def _fresh(name):
return _cached_file_fresh(output_dir / sub_dir_name / name, src_mtime)
# AVIF + vignette intermédiaire d'un côté, vignettes de l'autre :
# un changement de taille de vignette (dalle source inchangée)
# re-découpe depuis les AVIF de sous-tuiles déjà présents, sans
# ré-encoder ces derniers (des minutes de CPU par rebuild).
heavy_fresh = all(_fresh(stem + '.avif') and _fresh(stem + '_mid.webp')
for stem in stems.values())
thumbs_fresh = all(_fresh(stem + thumb_suffix) for stem in stems.values())
if heavy_fresh and not thumbs_fresh:
logger.info(f" Vignettes de sous-tuiles recalculées : "
f"{tile['dir_name']}/{viz_key} ({len(stems)} découpages)")
try:
for stem in stems.values():
with PILImage.open(str(output_dir / sub_dir_name / (stem + '.avif'))) as q:
q.load()
if q.mode not in ('RGB', 'L'):
q = q.convert('RGB')
_save_thumb(q, stem)
# Vignette 256 px d'une génération précédente
(output_dir / sub_dir_name / (stem + '_thumb.webp')).unlink(missing_ok=True)
except Exception as e:
logger.debug(f"Re-découpe des vignettes impossible ({viz_key}) : {e}")
heavy_fresh = False
if not heavy_fresh:
logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} "
f"({len(stems)} découpages)")
try:
@ -799,8 +838,10 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
f"repli dalle entière pour {viz_key} : {e}")
cut_failed = True
break
# Supprime l'ancien crop .webp d'une génération précédente
# Supprime les anciens crops d'une génération précédente
# (repli .webp, vignette 256 px sans taille dans le nom)
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
(output_dir / sub_dir_name / (stem + '_thumb.webp')).unlink(missing_ok=True)
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
mid_img = quad
if mid_scale < 1.0:
@ -808,19 +849,14 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
max(1, int(quad.size[1] * mid_scale))), resample)
mid_img.save(str(output_dir / sub_dir_name / (stem + '_mid.webp')),
format='WEBP', quality=82)
scale = min(1.0, 256 / max(quad.size))
if scale < 1.0:
quad = quad.resize((max(1, int(quad.size[0] * scale)),
max(1, int(quad.size[1] * scale))), resample)
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
format='WEBP', quality=80)
_save_thumb(quad, stem)
if cut_failed:
_fallback_full_dalle(entries, viz_key, info)
continue
v = _url_version(src_mtime)
for (i, j), stem in stems.items():
entries[(i, j)]['viz'][viz_key] = {
'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}",
'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}",
}
@ -2092,6 +2128,7 @@ function syncTileLayers() {
img.dataset.layer = k;
img.alt = '';
img.decoding = 'async';
img.loading = 'lazy';
div.appendChild(img);
entry.imgs[k] = img;
}
@ -2102,38 +2139,96 @@ function syncTileLayers() {
updateImages();
}
// Bascule vignette (256 px) ↔ vignette intermédiaire (640 px) ↔ image
// pleine résolution selon le zoom : en vue d'ensemble la vignette suffit ;
// au-delà de ~900 px à l'écran on charge l'image complète pour conserver
// les détails, l'intermédiaire faisant la transition. En dézoomant on
// retombe sur la vignette pour libérer la mémoire. Visibilité et tailles
// sont calculées en coordonnées projetées : aucun reflow.
const PRELOAD_MARGIN_PX = 300;
const FULL_RES_PX = 900;
// Bascule vignette ↔ vignette intermédiaire (640 px) ↔ image pleine
// résolution selon le zoom, avec trois garde-fous mémoire :
// - hors de l'écran (+ marge de préchargement), l'<img> est vidée de son
// src : ni téléchargement ni décodage pour les tuiles éloignées (et à
// l'ouverture de la page, seules les tuiles visibles se chargent) ;
// - la pleine résolution (≈ 25 Mo décodés par AVIF 2500²) ne se charge
// que pour les sous-tuiles à l'écran — l'anneau de 150 px ne précharge
// que le palier intermédiaire, pour que les tuiles qui entrent à l'écran
// lors d'un pan soient déjà nettes ;
// - le nombre d'images pleine résolution est plafonné (FULL_CAP) : au-delà,
// les sous-tuiles les plus éloignées du centre retombent sur le palier
// intermédiaire. En dézoomant, chaque palier libère la mémoire décodée.
// Les changements de palier passent par setImgSrc : l'image est pré-décodée
// en tâche de fond puis échangée d'un coup — jamais de flash blanc au zoom
// ou au pan. Visibilité et tailles sont calculées en coordonnées projetées :
// aucun reflow.
const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran
const MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide)
const BIG_PX = 220; // en dessous, la vignette suffit
const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire
const FULL_CAP = 24; // images pleine résolution max, toutes couches
// Change le src d'une <img> sans flash : l'image est d'abord téléchargée et
// décodée hors écran, le src n'est posé qu'une fois prête (le cache du
// navigateur sert alors la bitmap décodée instantanément). dataset.want
// invalide l'échange si un updateImages plus récent a décidé d'un autre
// palier entre-temps (pan rapide pendant le décodage).
function setImgSrc(img, url) {
if (img.src.endsWith(url)) { img.dataset.want = url; return; }
img.dataset.want = url;
const pre = new Image();
pre.decoding = 'async';
pre.src = url;
const apply = () => { if (img.dataset.want === url) img.src = url; };
if (pre.decode) pre.decode().then(apply, apply);
else apply();
}
function updateImages() {
if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire
const zoom = map.getZoom();
const b = map.getBounds();
const swP = map.project(b.getSouthWest(), zoom);
const neP = map.project(b.getNorthEast(), zoom);
const visBounds = L.latLngBounds(
map.unproject(L.point(swP.x - PRELOAD_MARGIN_PX, swP.y + PRELOAD_MARGIN_PX), zoom),
map.unproject(L.point(neP.x + PRELOAD_MARGIN_PX, neP.y - PRELOAD_MARGIN_PX), zoom));
const vp = map.getBounds();
const swP = map.project(vp.getSouthWest(), zoom);
const neP = map.project(vp.getNorthEast(), zoom);
const ctrP = L.point((swP.x + neP.x) / 2, (swP.y + neP.y) / 2);
const expand = (margin) => L.latLngBounds(
map.unproject(L.point(swP.x - margin, swP.y + margin), zoom),
map.unproject(L.point(neP.x + margin, neP.y - margin), zoom));
const visBounds = expand(PRELOAD_MARGIN_PX);
const midBounds = expand(MID_MARGIN_PX);
// Candidats pleine résolution : triés du plus proche au plus lointain du
// centre, seuls les FULL_CAP premiers l'obtiennent.
const fullWant = [];
const plan = [];
for (const entry of markers.values()) {
const visible = visBounds.intersects(entry.bounds);
const onScreen = vp.intersects(entry.bounds);
const near = midBounds.intersects(entry.bounds);
const p = tileSize(entry);
const big = visible && (p.w > 300 || p.h > 300);
const big = near && (p.w > BIG_PX || p.h > BIG_PX);
const dist = map.project(entry.bounds.getCenter(), zoom).distanceTo(ctrP);
for (const k in entry.imgs) {
const v = entry.t.viz[k];
if (!v) continue;
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
let want = v.thumb;
if (big && state.layers.opacity[k] > 0.15) {
want = (v.mid && Math.max(p.w, p.h) < FULL_RES_PX) ? v.mid : v.full;
}
const img = entry.imgs[k];
if (!img.src.endsWith(want)) img.src = want;
const v = entry.t.viz[k];
if (!visible || !v) { 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) {
if (!onScreen) {
want = v.mid || v.thumb; // anneau : préchargement intermédiaire
} else if (Math.max(p.w, p.h) < FULL_RES_PX) {
want = v.mid || v.full;
} else {
fullWant.push({ img: img, v: v, dist: dist });
continue;
}
}
plan.push([img, want]);
}
}
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)]));
for (const pair of plan) {
const img = pair[0], want = pair[1];
if (!want) {
img.dataset.want = '';
if (img.getAttribute('src') !== null) img.removeAttribute('src');
} else {
setImgSrc(img, want);
}
}
}
@ -2310,11 +2405,8 @@ const BUILTIN_PRESETS = [
{ label: 'Cavités', on: ['slope', 'positive_openness'],
opacity: { slope: 0.5, positive_openness: 1 },
osm: { on: false } },
{ label: 'Rugosité', on: ['roughness'],
opacity: {},
osm: { on: true, opacity: 0.3 } },
{ label: 'Tout', on: ['slope', 'aspect', 'positive_openness', 'roughness'],
opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55, roughness: 0.8 },
{ label: 'Tout', on: ['slope', 'aspect', 'positive_openness'],
opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55 },
osm: { on: true, opacity: 0.85, dark: true } },
];