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:
@ -201,7 +201,7 @@ VIZ_LEGENDS = {
|
|||||||
# Couches activées par défaut à l'ouverture de la carte : seules celles-ci
|
# 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
|
# sont allumées, toutes les autres sont désactivées (mais restent accessibles
|
||||||
# via le panneau). Ordre = pile basse → haute.
|
# 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
|
# Couche « principale » (mise en avant ; index_tiles.json + affichage en
|
||||||
# direct de la webapp).
|
# direct de la webapp).
|
||||||
@ -273,6 +273,12 @@ _CARTO_SUBTILED_VIZ = ()
|
|||||||
_SUBTILE_AVIF_QUALITY = 55
|
_SUBTILE_AVIF_QUALITY = 55
|
||||||
_SUBTILE_AVIF_SPEED = 8
|
_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) :
|
# Couches à contenu photographique ou traits fins (orthophoto, carte topo) :
|
||||||
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
|
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
|
||||||
# relevée pour ces couches uniquement.
|
# relevée pour ces couches uniquement.
|
||||||
@ -751,6 +757,18 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
resample = getattr(PILImage, 'LANCZOS', 1)
|
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:
|
for viz_key in offered_viz_keys:
|
||||||
info = tile['viz'].get(viz_key)
|
info = tile['viz'].get(viz_key)
|
||||||
if not info:
|
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.
|
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
|
||||||
src = output_dir / info['full'].split('?')[0]
|
src = output_dir / info['full'].split('?')[0]
|
||||||
src_mtime = _mtime(src)
|
src_mtime = _mtime(src)
|
||||||
all_exist = all(
|
|
||||||
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
|
def _fresh(name):
|
||||||
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime)
|
return _cached_file_fresh(output_dir / sub_dir_name / name, src_mtime)
|
||||||
and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_mid.webp'), 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())
|
for stem in stems.values())
|
||||||
if not all_exist:
|
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} "
|
logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} "
|
||||||
f"({len(stems)} découpages)")
|
f"({len(stems)} découpages)")
|
||||||
try:
|
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}")
|
f"repli dalle entière pour {viz_key} : {e}")
|
||||||
cut_failed = True
|
cut_failed = True
|
||||||
break
|
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 + '.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_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
|
||||||
mid_img = quad
|
mid_img = quad
|
||||||
if mid_scale < 1.0:
|
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)
|
max(1, int(quad.size[1] * mid_scale))), resample)
|
||||||
mid_img.save(str(output_dir / sub_dir_name / (stem + '_mid.webp')),
|
mid_img.save(str(output_dir / sub_dir_name / (stem + '_mid.webp')),
|
||||||
format='WEBP', quality=82)
|
format='WEBP', quality=82)
|
||||||
scale = min(1.0, 256 / max(quad.size))
|
_save_thumb(quad, stem)
|
||||||
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)
|
|
||||||
if cut_failed:
|
if cut_failed:
|
||||||
_fallback_full_dalle(entries, viz_key, info)
|
_fallback_full_dalle(entries, viz_key, info)
|
||||||
continue
|
continue
|
||||||
v = _url_version(src_mtime)
|
v = _url_version(src_mtime)
|
||||||
for (i, j), stem in stems.items():
|
for (i, j), stem in stems.items():
|
||||||
entries[(i, j)]['viz'][viz_key] = {
|
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}",
|
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
|
||||||
'full': f"{sub_dir_name}/{stem}.avif{v}",
|
'full': f"{sub_dir_name}/{stem}.avif{v}",
|
||||||
}
|
}
|
||||||
@ -2092,6 +2128,7 @@ function syncTileLayers() {
|
|||||||
img.dataset.layer = k;
|
img.dataset.layer = k;
|
||||||
img.alt = '';
|
img.alt = '';
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
|
img.loading = 'lazy';
|
||||||
div.appendChild(img);
|
div.appendChild(img);
|
||||||
entry.imgs[k] = img;
|
entry.imgs[k] = img;
|
||||||
}
|
}
|
||||||
@ -2102,38 +2139,96 @@ function syncTileLayers() {
|
|||||||
updateImages();
|
updateImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bascule vignette (256 px) ↔ vignette intermédiaire (640 px) ↔ image
|
// Bascule vignette ↔ vignette intermédiaire (640 px) ↔ image pleine
|
||||||
// pleine résolution selon le zoom : en vue d'ensemble la vignette suffit ;
|
// résolution selon le zoom, avec trois garde-fous mémoire :
|
||||||
// au-delà de ~900 px à l'écran on charge l'image complète pour conserver
|
// - hors de l'écran (+ marge de préchargement), l'<img> est vidée de son
|
||||||
// les détails, l'intermédiaire faisant la transition. En dézoomant on
|
// src : ni téléchargement ni décodage pour les tuiles éloignées (et à
|
||||||
// retombe sur la vignette pour libérer la mémoire. Visibilité et tailles
|
// l'ouverture de la page, seules les tuiles visibles se chargent) ;
|
||||||
// sont calculées en coordonnées projetées : aucun reflow.
|
// - la pleine résolution (≈ 25 Mo décodés par AVIF 2500²) ne se charge
|
||||||
const PRELOAD_MARGIN_PX = 300;
|
// que pour les sous-tuiles à l'écran — l'anneau de 150 px ne précharge
|
||||||
const FULL_RES_PX = 900;
|
// 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() {
|
function updateImages() {
|
||||||
if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire
|
if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire
|
||||||
const zoom = map.getZoom();
|
const zoom = map.getZoom();
|
||||||
const b = map.getBounds();
|
const vp = map.getBounds();
|
||||||
const swP = map.project(b.getSouthWest(), zoom);
|
const swP = map.project(vp.getSouthWest(), zoom);
|
||||||
const neP = map.project(b.getNorthEast(), zoom);
|
const neP = map.project(vp.getNorthEast(), zoom);
|
||||||
const visBounds = L.latLngBounds(
|
const ctrP = L.point((swP.x + neP.x) / 2, (swP.y + neP.y) / 2);
|
||||||
map.unproject(L.point(swP.x - PRELOAD_MARGIN_PX, swP.y + PRELOAD_MARGIN_PX), zoom),
|
const expand = (margin) => L.latLngBounds(
|
||||||
map.unproject(L.point(neP.x + PRELOAD_MARGIN_PX, neP.y - PRELOAD_MARGIN_PX), zoom));
|
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()) {
|
for (const entry of markers.values()) {
|
||||||
const visible = visBounds.intersects(entry.bounds);
|
const visible = visBounds.intersects(entry.bounds);
|
||||||
|
const onScreen = vp.intersects(entry.bounds);
|
||||||
|
const near = midBounds.intersects(entry.bounds);
|
||||||
const p = tileSize(entry);
|
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) {
|
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];
|
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'],
|
{ label: 'Cavités', on: ['slope', 'positive_openness'],
|
||||||
opacity: { slope: 0.5, positive_openness: 1 },
|
opacity: { slope: 0.5, positive_openness: 1 },
|
||||||
osm: { on: false } },
|
osm: { on: false } },
|
||||||
{ label: 'Rugosité', on: ['roughness'],
|
{ label: 'Tout', on: ['slope', 'aspect', 'positive_openness'],
|
||||||
opacity: {},
|
opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55 },
|
||||||
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 },
|
|
||||||
osm: { on: true, opacity: 0.85, dark: true } },
|
osm: { on: true, opacity: 0.85, dark: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -239,12 +239,12 @@ def test_build_index_generates_html(tmp_path):
|
|||||||
assert "Couches" in app_js or "layers" in app_js
|
assert "Couches" in app_js or "layers" in app_js
|
||||||
# Barre de génération : sélecteur restreint au panneau (PANEL_VIZ =
|
# Barre de génération : sélecteur restreint au panneau (PANEL_VIZ =
|
||||||
# DEFAULT_LAYERS ; valeurs = noms d'étapes --only). Les couches hors
|
# DEFAULT_LAYERS ; valeurs = noms d'étapes --only). Les couches hors
|
||||||
# panneau (svf, hillshade, wavelet) ne sont plus proposées.
|
# panneau (svf, hillshade, wavelet, roughness) ne sont plus proposées.
|
||||||
assert 'id="genViz"' in content
|
assert 'id="genViz"' in content
|
||||||
assert 'value="slope"' in content
|
assert 'value="slope"' in content
|
||||||
assert 'value="pos_open"' in content
|
assert 'value="pos_open"' in content
|
||||||
assert 'value="aspect"' in content
|
assert 'value="aspect"' in content
|
||||||
assert 'value="roughness"' in content
|
assert 'value="roughness"' not in content
|
||||||
assert 'value="wavelet"' not in content
|
assert 'value="wavelet"' not in content
|
||||||
assert 'value="svf"' not in content
|
assert 'value="svf"' not in content
|
||||||
assert 'value="hillshade"' not in content
|
assert 'value="hillshade"' not in content
|
||||||
|
|||||||
Reference in New Issue
Block a user