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

@ -172,7 +172,8 @@ if not (_assets_dir / "app.js").is_file():
from fastapi.staticfiles import StaticFiles as _StaticFiles
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_LOCKS = {}
_FETCH_LOCKS_GUARD = threading.Lock()
@ -259,20 +260,21 @@ def _fetch_remote_to_cache(rel_path):
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
toujours une URL invalidée après régénération : le ?v= suit la mtime de
la SOURCE, pas celle du fichier servi (vignette recalculée après coup,
cache webapp rapatrié…). Sans Cache-Control, le cache heuristique du
navigateur peut continuer d'afficher l'ancien rendu sur la même URL.
no-cache force la revalidation à chaque affichage — ETag/Last-Modified
la rend quasi gratuite quand le fichier n'a pas changé (réponse 304).
Les URLs des images portent ?v= (mtime du fichier servi, posée par
build_index) : le contenu qu'elles désignent ne change jamais → cache
immutable (1 an, jamais revalidé). Sans ?v= (index.html vu directement,
rejeu manuel d'URL), on garde no-cache : revalidation 304 quasi gratuite
via ETag/Last-Modified, et jamais de rendu périmé après régénération.
"""
async def get_response(self, 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
@ -317,7 +319,8 @@ class _OnDemandStaticFiles(_NoCacheStaticFiles):
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.mkdir(parents=True, exist_ok=True)
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}")
def assets(file_path: str):
"""Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild)."""
def assets(file_path: str, v: str = None):
"""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()
p = (_assets_dir / file_path).resolve()
if base not in p.parents or not p.is_file():
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).
@ -1033,8 +1044,13 @@ def tiles_data(stamp: Optional[float] = None):
if PANEL_VIZ is not None:
allowed = set(PANEL_VIZ)
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 {
"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,
"stats": (remote or data or {}).get("stats") or {},
"stamp": current,
@ -1073,6 +1089,8 @@ def _merge_remote_index():
if k not in keys:
merged.append(t)
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.update(remote.get("viz_meta") or {})
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}
path.write_text(json.dumps({
"tiles": merged,
"overview": overview,
"viz_meta": viz_meta,
# Les stats décrivent les réglages de l'INTERFACE (couches par
# 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")
# 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
# « 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"
try:
html = html_path.read_text(encoding="utf-8")
@ -1099,6 +1120,10 @@ def _merge_remote_index():
"const TILES = " + json.dumps(merged, ensure_ascii=False)
.replace("</", "<\\/") + ";",
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:
html_path.write_text(new_html, encoding="utf-8")
except OSError: