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