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

@ -262,6 +262,27 @@ des générations.
> `serve-webapp.sh` le fait automatiquement). Le téléphone accepte alors > `serve-webapp.sh` le fait automatiquement). Le téléphone accepte alors
> l'avertissement « certificat non fiable » une fois. > l'avertissement « certificat non fiable » une fois.
## Performance réseau (HTTP/2)
Le serveur interne (uvicorn) ne parle que **HTTP/1.1** : le navigateur y
limite chaque origine à 6 connexions simultanées. Peu contraignant depuis
l'optimisation de la carte (URLs versionnées servies `Cache-Control:
immutable` — zéro revalidation une fois chargées — et mosaïque d'overview à
la place des centaines de vignettes en vue dézoomée), mais pour un premier
chargement ou un parcours intensif, **passer par Traefik (HTTPS)** active
HTTP/2 entre le navigateur et le proxy : toutes les requêtes se multiplexent
sur une seule connexion (fin de la file d'attente des 6 connexions).
- **Via l'entrée `websecure` de Traefik** (route `https://lidar.example.fr`…)
: HTTP/2 est déjà actif — comportement par défaut de Traefik sur TLS.
Vérification : `curl -sI --http2 https://<domaine>/ | head -1` → `HTTP/2 200`.
- **En direct sur `http://<ip-pi>:8973`** (LAN) : HTTP/1.1 — les navigateurs
ne négocient pas h2 en clair. Si le besoin s'en fait sentir, exposer une
seconde route Traefik vers le même service (h2 côté client, le proxy
parlant h1 à uvicorn en backend).
- Entre Traefik et le conteneur, rien à changer : la limite qui compte est
celle du navigateur vers le frontend.
## Mise à jour ## Mise à jour
Sur le Pi, après un `git pull` : Sur le Pi, après un `git pull` :

View File

@ -305,6 +305,18 @@ _SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'})
# complet dès qu'une tuile dépasse ~300 px à l'écran. # complet dès qu'une tuile dépasse ~300 px à l'écran.
_MID_THUMB_SIZE = 640 _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. # Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli.
_VIZ_FALLBACK_ORDER = [ _VIZ_FALLBACK_ORDER = [
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness', 'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
@ -622,13 +634,13 @@ def _cached_file_fresh(path, src_mtime):
def _url_version(mtime): def _url_version(mtime):
"""Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu. """Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu.
Les images de la carte sont servies par des montages statiques sans Les images de la carte sont servies avec un cache immutable quand l'URL
Cache-Control : après une régénération, le cache heuristique du navigateur porte ?v= : le suffixe DOIT donc identifier le contenu du fichier servi,
peut continuer d'afficher l'ancien rendu (même URL = image considérée pas celui de sa source (une vignette recalculée après coup, ou un cache
fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un webapp rapatrié, change de contenu sans que la source bouge). Chaque URL
recalcul change l'URL et force le rechargement — y compris en direct est versionnée par la mtime de SON fichier. Un recalcul change l'URL et
pendant un run : pollLiveTiles fusionne les nouvelles données et force le rechargement — y compris en direct pendant un run : pollLiveTiles
updateImages() voit un src différent. fusionne les nouvelles données et updateImages() voit un src différent.
""" """
return f"?v={int(mtime * 1000)}" if mtime is not None else "" 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: if cut_failed:
_fallback_full_dalle(entries, viz_key, info) _fallback_full_dalle(entries, viz_key, info)
continue 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(): 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_suffix}{v}", 'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{_file_v(stem + thumb_suffix)}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}", 'mid': f"{sub_dir_name}/{stem}_mid.webp{_file_v(stem + '_mid.webp')}",
'full': f"{sub_dir_name}/{stem}.avif{v}", 'full': f"{sub_dir_name}/{stem}.avif{_file_v(stem + '.avif')}",
} }
except Exception as e: except Exception as e:
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {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 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): def _viz_src_dir(tile, info):
"""Dossier réel du fichier d'une visualisation. """Dossier réel du fichier d'une visualisation.
@ -1068,13 +1188,18 @@ def build_index(output_dir, output_format='avif'):
else: else:
thumbs_generated += 1 thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name 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] = { viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}{v}", 'thumb': f"index_thumbs/{thumb_name}"
'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}", 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(): 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: if regen:
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — " 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: if n_sous:
logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)") 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 # 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 # /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. # la page — le pipeline le réécrit après chaque tuile en mode incrémental.
ordered_viz, default_viz = _panel_selection(all_viz_keys) ordered_viz, default_viz = _panel_selection(all_viz_keys)
(output_dir / 'index_tiles.json').write_text(json.dumps({ (output_dir / 'index_tiles.json').write_text(json.dumps({
'tiles': display_tiles, 'tiles': display_tiles,
'overview': overview,
'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz}, 'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz},
'stats': {'default_viz': default_viz, 'stats': {'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys), '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') }, ensure_ascii=False), encoding='utf-8')
_write_assets(output_dir) _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 = output_dir / 'index.html'
html_path.write_text(html, encoding='utf-8') 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("</", "<\\/") 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/).""" """Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
ordered_viz, default_viz = _panel_selection(all_viz_keys) ordered_viz, default_viz = _panel_selection(all_viz_keys)
tiles_json = _json_for_script(tiles) 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) : # 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 # 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( return _HTML_TEMPLATE.format(
tiles_json=tiles_json, tiles_json=tiles_json,
overview_json=overview_json,
viz_meta_json=viz_meta_json, viz_meta_json=viz_meta_json,
stats_json=stats_json, stats_json=stats_json,
assets_v=assets_v, assets_v=assets_v,
@ -1466,6 +1604,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<script> <script>
const TILES = {tiles_json}; const TILES = {tiles_json};
const OVERVIEW = {overview_json};
const VIZ_META = {viz_meta_json}; const VIZ_META = {viz_meta_json};
const STATS = {stats_json}; const STATS = {stats_json};
</script> </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: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); } .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) --- */ /* --- Fiche tuile (onglet Tuile) --- */
.tile-info { padding: 6px 6px 10px; } .tile-info { padding: 6px 6px 10px; }
@ -2200,6 +2347,139 @@ function recomputeGridBounds() {
for (const t of TILES) registerTile(t); for (const t of TILES) registerTile(t);
recomputeGridBounds(); 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) { function entryDiv(entry) {
if (!entry.div) { if (!entry.div) {
const el = entry.mk.getElement(); const el = entry.mk.getElement();
@ -2250,6 +2530,7 @@ function layoutTiles() {
entry.proj = { w: w, h: h }; entry.proj = { w: w, h: h };
entry.projZoom = zoom; entry.projZoom = zoom;
} }
layoutOverview();
// Resynchronise aussi les <img> : au premier rendu les marqueurs viennent // Resynchronise aussi les <img> : au premier rendu les marqueurs viennent
// tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés. // tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés.
syncTileLayers(); syncTileLayers();
@ -2280,6 +2561,32 @@ function syncTileLayers() {
div.appendChild(img); div.appendChild(img);
entry.imgs[k] = 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.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]); img.style.opacity = String(state.layers.opacity[k]);
img.style.mixBlendMode = state.layers.blend[k] || 'normal'; 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)); map.unproject(L.point(neP.x + margin, neP.y - margin), zoom));
const visBounds = expand(PRELOAD_MARGIN_PX); const visBounds = expand(PRELOAD_MARGIN_PX);
const midBounds = expand(MID_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 // Candidats pleine résolution : triés du plus proche au plus lointain du
// centre, seuls les FULL_CAP premiers l'obtiennent. // centre, seuls les FULL_CAP premiers l'obtiennent.
const fullWant = []; const fullWant = [];
@ -2352,7 +2672,20 @@ function updateImages() {
for (const k in entry.imgs) { for (const k in entry.imgs) {
const img = entry.imgs[k]; const img = entry.imgs[k];
const v = entry.t.viz[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; let want = v.thumb;
// Les couches quasi transparentes n'ont pas besoin de pleine résolution // Les couches quasi transparentes n'ont pas besoin de pleine résolution
if (big && state.layers.opacity[k] > 0.15) { if (big && state.layers.opacity[k] > 0.15) {
@ -2368,6 +2701,23 @@ function updateImages() {
plan.push([img, want]); 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.sort((a, b) => a.dist - b.dist);
fullWant.forEach((c, i) => plan.push( fullWant.forEach((c, i) => plan.push(
[c.img, i < FULL_CAP ? c.v.full : (c.v.mid || c.v.thumb)])); [c.img, i < FULL_CAP ? c.v.full : (c.v.mid || c.v.thumb)]));
@ -2377,9 +2727,18 @@ function updateImages() {
img.dataset.want = ''; img.dataset.want = '';
if (img.getAttribute('src') !== null) img.removeAttribute('src'); if (img.getAttribute('src') !== null) img.removeAttribute('src');
} else { } else {
img.classList.remove('fading');
setImgSrc(img, want); 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; 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) { if (changed) {
recomputeGridBounds(); recomputeGridBounds();
const statTiles = document.getElementById('statTiles'); const statTiles = document.getElementById('statTiles');
@ -3932,7 +4306,7 @@ function mergeTilesData(data) {
} }
} }
if (changed || metaChanged) renderLayerPanel(); if (changed || metaChanged) renderLayerPanel();
if (changed) { if (changed || ovChanged) {
syncTileLayers(); layoutTiles(); syncTileLayers(); layoutTiles();
// Une dalle qui vient d'apparaître prend le relais du polygone de // Une dalle qui vient d'apparaître prend le relais du polygone de
// sélection (surlignage du marqueur désormais possible) // sélection (surlignage du marqueur désormais possible)

View File

@ -512,11 +512,10 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
def test_urls_versioned_for_cache_busting(tmp_path): def test_urls_versioned_for_cache_busting(tmp_path):
"""Les URLs images changent quand une tuile est régénérée. """Les URLs images changent quand une tuile est régénérée.
Les images sont servies par des montages statiques sans Cache-Control : Les URLs versionnées (?v=) sont servies avec un cache immutable : un
sans version d'URL, le navigateur garde son ancien rendu en cache même recalcul DOIT changer l'URL pour forcer le rechargement, y compris en
après recalcul. Chaque URL (vignette, intermédiaire, pleine) porte la direct pendant un run (--incremental-index). Chaque URL porte la mtime
mtime de sa source en ?v= — un recalcul change l'URL et force le de SON fichier (cf. test_urls_versioned_by_served_file_mtime).
rechargement, y compris en direct pendant un run (--incremental-index).
""" """
import os import os
import re import re
@ -700,3 +699,90 @@ def test_approx_wgs84_to_l93_roundtrip():
lon, lat = _approx_l93_to_wgs84(x, y) lon, lat = _approx_l93_to_wgs84(x, y)
x2, y2 = _approx_wgs84_to_l93(lon, lat) x2, y2 = _approx_wgs84_to_l93(lon, lat)
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6 assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6
def test_overview_generated_for_large_grids(tmp_path):
"""Grille >= 6 dalles : une mosaïque d'overview par couche + le payload."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
for col in (1000, 1001, 1002):
for row in (6881, 6882):
_make_fake_viz_dir(vis_dir, "t", col, row, ('hillshade_multi', 'svf'))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
ov = data['overview']
assert 'hillshade_multi' in ov and 'svf' in ov
meta = ov['hillshade_multi']
assert (output_dir / meta['url'].split('?')[0]).is_file()
assert meta['km_w'] == 3 and meta['km_h'] == 2
assert meta['w'] == 3 * meta['px_per_km']
assert meta['h'] == 2 * meta['px_per_km']
assert len(meta['corners']) == 4
# La coquille HTML embarque la constante pour l'interface
html = (output_dir / "index.html").read_text(encoding='utf-8')
assert "const OVERVIEW" in html
assert "index_overview/hillshade_multi.webp" in html
def test_overview_skipped_for_small_grids(tmp_path):
"""Grille < 6 dalles : pas d'overview, les vignettes suffisent."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
assert data['overview'] == {}
def test_urls_versioned_by_served_file_mtime(tmp_path):
"""?v= suit la mtime du fichier SERVI — prérequis du cache immutable.
Une vignette recalculée après coup (source inchangée) change de contenu :
son URL doit changer aussi, sans toucher celles des fichiers intacts.
"""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
# 0,2 m : la dalle passe par le sous-tuilage (vignettes/mid/AVIF propres)
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',),
res_suffix='_r0p2')
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
import time
for t in data['tiles']:
for viz in t['viz'].values():
for tier in ('thumb', 'mid', 'full'):
url = viz.get(tier)
if not url or '?v=' not in url:
continue
path, v = url.split('?v=')
assert v.isdigit()
# La version est la mtime ms du fichier servi lui-même
# (tolérance 1 s : granularité du système de fichiers).
assert abs(int(v) - int((output_dir / path).stat().st_mtime * 1000)) < 1000
# Vignette 0,5 m recalculée après coup : seule SON URL change.
tiles05 = [t for t in data['tiles'] if t.get('resolution') == 0.5]
url_before = tiles05[0]['viz']['hillshade_multi']['thumb']
thumb_path = output_dir / url_before.split('?')[0]
time.sleep(0.05)
from PIL import Image as PILImage
PILImage.new('RGB', (30, 30), (200, 10, 10)).save(str(thumb_path), format='JPEG')
assert build_index(output_dir) is not None
data2 = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
tiles05b = [t for t in data2['tiles'] if t.get('resolution') == 0.5]
url_after = tiles05b[0]['viz']['hillshade_multi']['thumb']
assert url_after.split('?')[0] == url_before.split('?')[0]
assert url_after != url_before

View File

@ -385,9 +385,10 @@ def test_merge_remote_index_merges_files(tmp_path, monkeypatch):
"stats": {"n_tiles": 2}}), encoding="utf-8") "stats": {"n_tiles": 2}}), encoding="utf-8")
(tmp_path / "index.html").write_text( (tmp_path / "index.html").write_text(
"<html><script>const TILES = " + _json.dumps([locale, perimee]) "<html><script>const TILES = " + _json.dumps([locale, perimee])
+ ";</script></html>", encoding="utf-8") + ";</script>\nconst OVERVIEW = {};\n</script></html>", encoding="utf-8")
monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: { monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: {
"tiles": [dist_neuve, dist_seule], "tiles": [dist_neuve, dist_seule],
"overview": {"hillshade_multi": {"url": "index_overview/x.webp?v=2"}},
"viz_meta": {"aspect": {"label": "Aspect distant"}}, "viz_meta": {"aspect": {"label": "Aspect distant"}},
"stats": {"n_tiles": 2}, "stats": {"n_tiles": 2},
}) })
@ -398,10 +399,12 @@ def test_merge_remote_index_merges_files(tmp_path, monkeypatch):
assert "distante-neuve" in names and "locale" in names and "distante-seule" in names assert "distante-neuve" in names and "locale" in names and "distante-seule" in names
assert "locale-perimee" not in names assert "locale-perimee" not in names
assert data["viz_meta"]["aspect"]["label"] == "Aspect distant" assert data["viz_meta"]["aspect"]["label"] == "Aspect distant"
# La coquille embarque la même liste fusionnée. # La coquille embarque la même liste fusionnée (et l'overview distante).
html = (tmp_path / "index.html").read_text(encoding="utf-8") html = (tmp_path / "index.html").read_text(encoding="utf-8")
assert "distante-neuve" in html and "distante-seule" in html assert "distante-neuve" in html and "distante-seule" in html
assert "locale-perimee" not in html assert "locale-perimee" not in html
assert "const OVERVIEW = {\"hillshade_multi\"" in html
assert data["overview"]["hillshade_multi"]["url"].endswith("?v=2")
# Worker injoignable : fichiers laissés intacts. # Worker injoignable : fichiers laissés intacts.
before = (tmp_path / "index_tiles.json").read_text(encoding="utf-8") before = (tmp_path / "index_tiles.json").read_text(encoding="utf-8")
monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None) monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None)
@ -409,12 +412,13 @@ def test_merge_remote_index_merges_files(tmp_path, monkeypatch):
assert (tmp_path / "index_tiles.json").read_text(encoding="utf-8") == before assert (tmp_path / "index_tiles.json").read_text(encoding="utf-8") == before
def test_static_mounts_served_no_cache(tmp_path): def test_static_mounts_cache_headers(tmp_path):
"""Les montages d'images servent Cache-Control: no-cache. """Montages d'images : no-cache par défaut, immutable si l'URL porte ?v=.
Le ?v= des URLs suit la mtime de la source, pas celle du fichier servi ?v= suit la mtime du fichier servi (posée par build_index) : le contenu
(vignette recalculée, cache webapp rapatrié) : sans revalidation imposée, désigné ne change jamais → cache navigateur 1 an sans revalidation. Sans
le cache heuristique du navigateur peut afficher l'ancien rendu. ?v= (index.html, rejeu manuel), no-cache force la revalidation 304 — un
rendu régénéré ne reste jamais périmé à l'écran.
""" """
import asyncio import asyncio
from starlette.routing import Mount from starlette.routing import Mount
@ -422,7 +426,8 @@ def test_static_mounts_served_no_cache(tmp_path):
assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles) assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles)
mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)} mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)}
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): for name in ("index_thumbs", "index_subtiles", "index_overview",
"visualisations", "DTM"):
route = mounted.get(f"/{name}") route = mounted.get(f"/{name}")
assert route is not None, f"montage /{name} absent" assert route is not None, f"montage /{name} absent"
assert isinstance(route.app, webapp._NoCacheStaticFiles) assert isinstance(route.app, webapp._NoCacheStaticFiles)
@ -431,13 +436,15 @@ def test_static_mounts_served_no_cache(tmp_path):
(tmp_path / "x.jpg").write_bytes(b"1") (tmp_path / "x.jpg").write_bytes(b"1")
srv = webapp._NoCacheStaticFiles(directory=str(tmp_path)) srv = webapp._NoCacheStaticFiles(directory=str(tmp_path))
async def fetch(): async def fetch(query):
scope = {"type": "http", "method": "GET", "path": "/x.jpg", scope = {"type": "http", "method": "GET", "path": "/x.jpg",
"headers": [], "query_string": b""} "headers": [], "query_string": query}
return await srv.get_response("x.jpg", scope) return await srv.get_response("x.jpg", scope)
resp = asyncio.run(fetch()) resp = asyncio.run(fetch(b""))
assert resp.headers["cache-control"] == "no-cache, must-revalidate" assert resp.headers["cache-control"] == "no-cache, must-revalidate"
resp = asyncio.run(fetch(b"v=1712345678901"))
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
def test_stop_generation(monkeypatch): def test_stop_generation(monkeypatch):

View File

@ -172,7 +172,8 @@ if not (_assets_dir / "app.js").is_file():
from fastapi.staticfiles import StaticFiles as _StaticFiles from fastapi.staticfiles import StaticFiles as _StaticFiles
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles") _ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles",
"index_overview")
_FETCH_SEMAPHORE = threading.Semaphore(4) _FETCH_SEMAPHORE = threading.Semaphore(4)
_FETCH_LOCKS = {} _FETCH_LOCKS = {}
_FETCH_LOCKS_GUARD = threading.Lock() _FETCH_LOCKS_GUARD = threading.Lock()
@ -259,20 +260,21 @@ def _fetch_remote_to_cache(rel_path):
class _NoCacheStaticFiles(_StaticFiles): class _NoCacheStaticFiles(_StaticFiles):
"""Fichiers statiques servis sans cache navigateur (revalidation 304). """Fichiers statiques : cache immutable si l'URL est versionnée (?v=).
Les images (visualisations, vignettes, sous-tuiles, DTM) n'ont pas Les URLs des images portent ?v= (mtime du fichier servi, posée par
toujours une URL invalidée après régénération : le ?v= suit la mtime de build_index) : le contenu qu'elles désignent ne change jamais → cache
la SOURCE, pas celle du fichier servi (vignette recalculée après coup, immutable (1 an, jamais revalidé). Sans ?v= (index.html vu directement,
cache webapp rapatrié…). Sans Cache-Control, le cache heuristique du rejeu manuel d'URL), on garde no-cache : revalidation 304 quasi gratuite
navigateur peut continuer d'afficher l'ancien rendu sur la même URL. via ETag/Last-Modified, et jamais de rendu périmé après régénération.
no-cache force la revalidation à chaque affichage — ETag/Last-Modified
la rend quasi gratuite quand le fichier n'a pas changé (réponse 304).
""" """
async def get_response(self, path, scope): async def get_response(self, path, scope):
resp = await super().get_response(path, scope) resp = await super().get_response(path, scope)
resp.headers["Cache-Control"] = "no-cache, must-revalidate" resp.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
if _version_from_query(scope) is not None
else "no-cache, must-revalidate")
return resp return resp
@ -317,7 +319,8 @@ class _OnDemandStaticFiles(_NoCacheStaticFiles):
return _fetch_remote_to_cache(rel) return _fetch_remote_to_cache(rel)
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): for name in ("index_thumbs", "index_subtiles", "index_overview",
"visualisations", "DTM"):
_dir = OUTPUT_DIR / name _dir = OUTPUT_DIR / name
_dir.mkdir(parents=True, exist_ok=True) _dir.mkdir(parents=True, exist_ok=True)
if name in _ONDEMAND_PREFIXES and GENERATION_URL: if name in _ONDEMAND_PREFIXES and GENERATION_URL:
@ -329,13 +332,21 @@ for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
@app.get("/assets/{file_path:path}") @app.get("/assets/{file_path:path}")
def assets(file_path: str): def assets(file_path: str, v: str = None):
"""Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild).""" """Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild).
Exception : ?v= (hash de contenu posé par build_index) → cache immutable.
"""
base = _assets_dir.resolve() base = _assets_dir.resolve()
p = (_assets_dir / file_path).resolve() p = (_assets_dir / file_path).resolve()
if base not in p.parents or not p.is_file(): if base not in p.parents or not p.is_file():
raise HTTPException(404, f"asset introuvable : {file_path}") raise HTTPException(404, f"asset introuvable : {file_path}")
return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"}) # ?v= : hash de contenu hexadécimal posé par build_index (l'URL change
# à chaque évolution du CSS/JS) → cache immutable.
cache = ("public, max-age=31536000, immutable"
if v
else "no-cache, must-revalidate")
return FileResponse(str(p), headers={"Cache-Control": cache})
# Méthodes de classification du sol acceptées (mêmes valeurs que --ground-classification). # Méthodes de classification du sol acceptées (mêmes valeurs que --ground-classification).
@ -1033,8 +1044,13 @@ def tiles_data(stamp: Optional[float] = None):
if PANEL_VIZ is not None: if PANEL_VIZ is not None:
allowed = set(PANEL_VIZ) allowed = set(PANEL_VIZ)
viz_meta = {k: v for k, v in viz_meta.items() if k in allowed} viz_meta = {k: v for k, v in viz_meta.items() if k in allowed}
merged_overview = dict((data or {}).get("overview") or {})
merged_overview.update((remote or {}).get("overview") or {})
return { return {
"tiles": merged, "tiles": merged,
# Mosaïques d'overview : le distant prime (grille recalculée),
# le local complète les couches que le worker n'a plus.
"overview": merged_overview,
"viz_meta": viz_meta, "viz_meta": viz_meta,
"stats": (remote or data or {}).get("stats") or {}, "stats": (remote or data or {}).get("stats") or {},
"stamp": current, "stamp": current,
@ -1073,6 +1089,8 @@ def _merge_remote_index():
if k not in keys: if k not in keys:
merged.append(t) merged.append(t)
keys.add(k) keys.add(k)
overview = dict(data.get("overview") or {})
overview.update(remote.get("overview") or {})
viz_meta = dict(data.get("viz_meta") or {}) viz_meta = dict(data.get("viz_meta") or {})
viz_meta.update(remote.get("viz_meta") or {}) viz_meta.update(remote.get("viz_meta") or {})
from .index import PANEL_VIZ from .index import PANEL_VIZ
@ -1081,6 +1099,7 @@ def _merge_remote_index():
viz_meta = {k: v for k, v in viz_meta.items() if k in allowed} viz_meta = {k: v for k, v in viz_meta.items() if k in allowed}
path.write_text(json.dumps({ path.write_text(json.dumps({
"tiles": merged, "tiles": merged,
"overview": overview,
"viz_meta": viz_meta, "viz_meta": viz_meta,
# Les stats décrivent les réglages de l'INTERFACE (couches par # Les stats décrivent les réglages de l'INTERFACE (couches par
# défaut) : elles viennent du rebuild local, fait avec le code de # défaut) : elles viennent du rebuild local, fait avec le code de
@ -1091,7 +1110,9 @@ def _merge_remote_index():
}, ensure_ascii=False), encoding="utf-8") }, ensure_ascii=False), encoding="utf-8")
# Coquille HTML : la liste embarquée doit être la même (fusionnée). Le # Coquille HTML : la liste embarquée doit être la même (fusionnée). Le
# JSON ne contient jamais de « ; » : la première séquence « ]; » après # JSON ne contient jamais de « ; » : la première séquence « ]; » après
# « const TILES = [ » termine forcément l'instruction. # « const TILES = [ » termine forcément l'instruction (idem « }; » pour
# const OVERVIEW : à l'intérieur d'un objet JSON, « } » n'est suivi que
# de « , » « ] » ou « } »).
html_path = OUTPUT_DIR / "index.html" html_path = OUTPUT_DIR / "index.html"
try: try:
html = html_path.read_text(encoding="utf-8") html = html_path.read_text(encoding="utf-8")
@ -1099,6 +1120,10 @@ def _merge_remote_index():
"const TILES = " + json.dumps(merged, ensure_ascii=False) "const TILES = " + json.dumps(merged, ensure_ascii=False)
.replace("</", "<\\/") + ";", .replace("</", "<\\/") + ";",
html, count=1, flags=re.S) html, count=1, flags=re.S)
new_html = re.sub(r"const OVERVIEW = \{.*?\};",
"const OVERVIEW = " + json.dumps(overview, ensure_ascii=False)
.replace("</", "<\\/") + ";",
new_html, count=1, flags=re.S)
if new_html != html: if new_html != html:
html_path.write_text(new_html, encoding="utf-8") html_path.write_text(new_html, encoding="utf-8")
except OSError: except OSError: