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
|
||||
> 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
|
||||
|
||||
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.
|
||||
_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.
|
||||
_VIZ_FALLBACK_ORDER = [
|
||||
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
|
||||
@ -622,13 +634,13 @@ def _cached_file_fresh(path, src_mtime):
|
||||
def _url_version(mtime):
|
||||
"""Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu.
|
||||
|
||||
Les images de la carte sont servies par des montages statiques sans
|
||||
Cache-Control : après une régénération, le cache heuristique du navigateur
|
||||
peut continuer d'afficher l'ancien rendu (même URL = image considérée
|
||||
fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un
|
||||
recalcul change l'URL et force le rechargement — y compris en direct
|
||||
pendant un run : pollLiveTiles fusionne les nouvelles données et
|
||||
updateImages() voit un src différent.
|
||||
Les images de la carte sont servies avec un cache immutable quand l'URL
|
||||
porte ?v= : le suffixe DOIT donc identifier le contenu du fichier servi,
|
||||
pas celui de sa source (une vignette recalculée après coup, ou un cache
|
||||
webapp rapatrié, change de contenu sans que la source bouge). Chaque URL
|
||||
est versionnée par la mtime de SON fichier. Un recalcul change l'URL et
|
||||
force le rechargement — y compris en direct pendant un run : pollLiveTiles
|
||||
fusionne les nouvelles données et updateImages() voit un src différent.
|
||||
"""
|
||||
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:
|
||||
_fallback_full_dalle(entries, viz_key, info)
|
||||
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():
|
||||
entries[(i, j)]['viz'][viz_key] = {
|
||||
'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{v}",
|
||||
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
|
||||
'full': f"{sub_dir_name}/{stem}.avif{v}",
|
||||
'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{_file_v(stem + thumb_suffix)}",
|
||||
'mid': f"{sub_dir_name}/{stem}_mid.webp{_file_v(stem + '_mid.webp')}",
|
||||
'full': f"{sub_dir_name}/{stem}.avif{_file_v(stem + '.avif')}",
|
||||
}
|
||||
except Exception as 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
|
||||
|
||||
|
||||
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):
|
||||
"""Dossier réel du fichier d'une visualisation.
|
||||
|
||||
@ -1068,13 +1188,18 @@ def build_index(output_dir, output_format='avif'):
|
||||
else:
|
||||
thumbs_generated += 1
|
||||
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] = {
|
||||
'thumb': f"index_thumbs/{thumb_name}{v}",
|
||||
'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}",
|
||||
'thumb': f"index_thumbs/{thumb_name}"
|
||||
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():
|
||||
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:
|
||||
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:
|
||||
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
|
||||
# /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.
|
||||
ordered_viz, default_viz = _panel_selection(all_viz_keys)
|
||||
(output_dir / 'index_tiles.json').write_text(json.dumps({
|
||||
'tiles': display_tiles,
|
||||
'overview': overview,
|
||||
'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz},
|
||||
'stats': {'default_viz': default_viz,
|
||||
'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')
|
||||
|
||||
_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.write_text(html, encoding='utf-8')
|
||||
|
||||
@ -1222,11 +1358,12 @@ def _json_for_script(obj):
|
||||
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/)."""
|
||||
ordered_viz, default_viz = _panel_selection(all_viz_keys)
|
||||
|
||||
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) :
|
||||
# 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(
|
||||
tiles_json=tiles_json,
|
||||
overview_json=overview_json,
|
||||
viz_meta_json=viz_meta_json,
|
||||
stats_json=stats_json,
|
||||
assets_v=assets_v,
|
||||
@ -1466,6 +1604,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
|
||||
<script>
|
||||
const TILES = {tiles_json};
|
||||
const OVERVIEW = {overview_json};
|
||||
const VIZ_META = {viz_meta_json};
|
||||
const STATS = {stats_json};
|
||||
</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.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) --- */
|
||||
.tile-info { padding: 6px 6px 10px; }
|
||||
@ -2200,6 +2347,139 @@ function recomputeGridBounds() {
|
||||
for (const t of TILES) registerTile(t);
|
||||
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) {
|
||||
if (!entry.div) {
|
||||
const el = entry.mk.getElement();
|
||||
@ -2250,6 +2530,7 @@ function layoutTiles() {
|
||||
entry.proj = { w: w, h: h };
|
||||
entry.projZoom = zoom;
|
||||
}
|
||||
layoutOverview();
|
||||
// Resynchronise aussi les <img> : au premier rendu les marqueurs viennent
|
||||
// tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés.
|
||||
syncTileLayers();
|
||||
@ -2280,6 +2561,32 @@ function syncTileLayers() {
|
||||
div.appendChild(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.opacity = String(state.layers.opacity[k]);
|
||||
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));
|
||||
const visBounds = expand(PRELOAD_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
|
||||
// centre, seuls les FULL_CAP premiers l'obtiennent.
|
||||
const fullWant = [];
|
||||
@ -2352,7 +2672,20 @@ function updateImages() {
|
||||
for (const k in entry.imgs) {
|
||||
const img = entry.imgs[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;
|
||||
// Les couches quasi transparentes n'ont pas besoin de pleine résolution
|
||||
if (big && state.layers.opacity[k] > 0.15) {
|
||||
@ -2368,6 +2701,23 @@ function updateImages() {
|
||||
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.forEach((c, i) => plan.push(
|
||||
[c.img, i < FULL_CAP ? c.v.full : (c.v.mid || c.v.thumb)]));
|
||||
@ -2377,9 +2727,18 @@ function updateImages() {
|
||||
img.dataset.want = '';
|
||||
if (img.getAttribute('src') !== null) img.removeAttribute('src');
|
||||
} else {
|
||||
img.classList.remove('fading');
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
recomputeGridBounds();
|
||||
const statTiles = document.getElementById('statTiles');
|
||||
@ -3932,7 +4306,7 @@ function mergeTilesData(data) {
|
||||
}
|
||||
}
|
||||
if (changed || metaChanged) renderLayerPanel();
|
||||
if (changed) {
|
||||
if (changed || ovChanged) {
|
||||
syncTileLayers(); layoutTiles();
|
||||
// Une dalle qui vient d'apparaître prend le relais du polygone de
|
||||
// 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):
|
||||
"""Les URLs images changent quand une tuile est régénérée.
|
||||
|
||||
Les images sont servies par des montages statiques sans Cache-Control :
|
||||
sans version d'URL, le navigateur garde son ancien rendu en cache même
|
||||
après recalcul. Chaque URL (vignette, intermédiaire, pleine) porte la
|
||||
mtime de sa source en ?v= — un recalcul change l'URL et force le
|
||||
rechargement, y compris en direct pendant un run (--incremental-index).
|
||||
Les URLs versionnées (?v=) sont servies avec un cache immutable : un
|
||||
recalcul DOIT changer l'URL pour forcer le rechargement, y compris en
|
||||
direct pendant un run (--incremental-index). Chaque URL porte la mtime
|
||||
de SON fichier (cf. test_urls_versioned_by_served_file_mtime).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
@ -700,3 +699,90 @@ def test_approx_wgs84_to_l93_roundtrip():
|
||||
lon, lat = _approx_l93_to_wgs84(x, y)
|
||||
x2, y2 = _approx_wgs84_to_l93(lon, lat)
|
||||
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")
|
||||
(tmp_path / "index.html").write_text(
|
||||
"<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: {
|
||||
"tiles": [dist_neuve, dist_seule],
|
||||
"overview": {"hillshade_multi": {"url": "index_overview/x.webp?v=2"}},
|
||||
"viz_meta": {"aspect": {"label": "Aspect distant"}},
|
||||
"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 "locale-perimee" not in names
|
||||
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")
|
||||
assert "distante-neuve" in html and "distante-seule" 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.
|
||||
before = (tmp_path / "index_tiles.json").read_text(encoding="utf-8")
|
||||
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
|
||||
|
||||
|
||||
def test_static_mounts_served_no_cache(tmp_path):
|
||||
"""Les montages d'images servent Cache-Control: no-cache.
|
||||
def test_static_mounts_cache_headers(tmp_path):
|
||||
"""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
|
||||
(vignette recalculée, cache webapp rapatrié) : sans revalidation imposée,
|
||||
le cache heuristique du navigateur peut afficher l'ancien rendu.
|
||||
?v= suit la mtime du fichier servi (posée par build_index) : le contenu
|
||||
désigné ne change jamais → cache navigateur 1 an sans revalidation. Sans
|
||||
?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
|
||||
from starlette.routing import Mount
|
||||
@ -422,7 +426,8 @@ def test_static_mounts_served_no_cache(tmp_path):
|
||||
|
||||
assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles)
|
||||
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}")
|
||||
assert route is not None, f"montage /{name} absent"
|
||||
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")
|
||||
srv = webapp._NoCacheStaticFiles(directory=str(tmp_path))
|
||||
|
||||
async def fetch():
|
||||
async def fetch(query):
|
||||
scope = {"type": "http", "method": "GET", "path": "/x.jpg",
|
||||
"headers": [], "query_string": b""}
|
||||
"headers": [], "query_string": query}
|
||||
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"
|
||||
resp = asyncio.run(fetch(b"v=1712345678901"))
|
||||
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
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 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:
|
||||
|
||||
Reference in New Issue
Block a user