Split webapp for Raspberry Pi deployment, remote generation API and sync

La webapp (carte + vignettes) et la génération de tuiles se déploient sur
deux machines : image légère Dockerfile.webapp (FastAPI + Pillow AVIF
natif + pyproj) sur Raspberry Pi, pipeline complet sur la machine de
traitement. LIDAR_GENERATION_URL délègue /api/generate, /api/preview et
/api/status ; /api/sync ramène les tuiles par rsync puis régénère
vignettes et index localement. Token partagé optionnel
(LIDAR_API_TOKEN/LIDAR_REMOTE_TOKEN). Retire du dépôt les journaux
internes (.swival, audit-findings) et les données (data/, notebooks/).
Doc : docs/DEPLOY_WEBAPP.md.
This commit is contained in:
Antoine Jacquin
2026-09-02 19:44:39 +02:00
parent ed3e90ea89
commit 422f58d772
24 changed files with 2390 additions and 329 deletions

View File

@ -41,7 +41,6 @@ VIZ_LABELS = {
'positive_openness': 'Openness positive',
'negative_openness': 'Openness négative',
'svf': 'Sky-View Factor',
'aniso_open': 'Openness anisotropique',
'roughness': 'Rugosité',
'wavelet': 'Ondelette',
'flow_acc': 'Accumulation d\'écoulement',
@ -54,26 +53,53 @@ VIZ_LABELS = {
# Couche activée par défaut à l'ouverture de la carte.
DEFAULT_VIZ = 'hillshade_multi'
# Couches proposées dans le panneau de couches et le sélecteur de génération
# (liste restreinte : base aspect + visualisations demandées). Les autres
# visualisations restent calculées et stockées mais ne sont pas proposées.
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
# Visualisations proposées dans le panneau de couches et le sélecteur de
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
# les couches réellement proposées à l'affichage (voir _render_html).
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
# Visualisations proposées dans la barre de génération de zones (noms
# d'étapes --only du pipeline) ; 'aspect' reste le choix par défaut (léger).
GEN_VIZ_CHOICES = [
('aspect', 'Aspect'),
('slope', 'Pente'),
('pos_open', 'Openness positive'),
('aniso_open', 'Openness anisotropique'),
('wavelet', 'Ondelette'),
]
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
# cf. _expected_output_path dans pipeline.py).
KEYWORD_TO_STEP = {
'hillshade_multi': 'hillshade',
'positive_openness': 'pos_open',
'negative_openness': 'neg_open',
}
# Correspondance inverse : nom d'étape --only → mot-clé de fichier de sortie.
STEP_TO_KEYWORD = {step: kw for kw, step in KEYWORD_TO_STEP.items()}
def step_to_keyword(step):
"""Nom d'étape du pipeline (ex: 'pos_open') → mot-clé de fichier ('positive_openness')."""
return STEP_TO_KEYWORD.get(step, step)
def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
"""Cellules (col, row) disposant de TOUTES les visualisations demandées.
Une cellule est complète si, pour chaque résolution de `resolutions`, un
dossier de visualisations lui correspond et contient chaque mot-clé de
`viz_keys` (ex: 'aspect', 'hillshade_multi'). Sert à distinguer les tuiles
réellement terminées de celles à compléter : une tuile existante mais
incomplète (visualisation ou résolution manquante) reste à traiter.
Returns:
Ensemble des (col, row) complets.
"""
by_cell = {}
for t in scan_tiles(vis_dir):
per_res = by_cell.setdefault((t['col'], t['row']), {})
per_res.setdefault(t['resolution'], set()).update(t['viz'].keys())
return {cell for cell, per_res in by_cell.items()
if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)}
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
# carte — les autres viz restent disponibles en repli dalle entière.
# Liste vide = découper toutes les visualisations disponibles.
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'aniso_open', 'wavelet')
'wavelet')
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
@ -85,7 +111,7 @@ _SUBTILE_AVIF_SPEED = 8
# 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',
'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness',
'negative_openness', 'aspect', 'sailore', 'roughness',
'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo',
]
@ -295,6 +321,18 @@ def _approx_l93_to_wgs84(x_m, y_m):
return lon, lat
def _approx_wgs84_to_l93(lon, lat):
"""Approximation affine WGS84 → Lambert 93 (inverse exacte du précédent).
Précision de l'ordre du km — repli sans rasterio ni pyproj (webapp
légère, cf. bbox_to_cells dans webapp.py).
"""
import math
y = (lat - 46.5) * 111320.0 + 6600000.0
x = (lon - 3.0) * (111320.0 * math.cos(math.radians(47.0))) + 700000.0
return x, y
def attach_gps_bounds(tiles):
"""Attache à chaque tuile ses coins GPS pour l'affichage Leaflet.
@ -302,25 +340,38 @@ def attach_gps_bounds(tiles):
(col, row) → X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
(Vérifié sur les bounds des DTM : X_min = col×1000, Y_max = row×1000.)
Utilise rasterio.warp (conversion PROJ exacte) si disponible,
sinon l'approximation affine _approx_l93_to_wgs84.
Utilise rasterio.warp (conversion PROJ exacte) si disponible, sinon
pyproj, sinon l'approximation affine _approx_l93_to_wgs84 (webapp
légère sans GDAL — précision ~km).
Ajoute à chaque tuile:
corners: [[lat, lon] × 4] dans l'ordre SW, SE, NE, NW
bounds : [[lat_sud, lon_ouest], [lat_nord, lon_est]]
"""
# Coins SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000
xs = []
ys = []
for t in tiles:
xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000,
(t['col'] + 1) * 1000, t['col'] * 1000])
ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000,
t['row'] * 1000, t['row'] * 1000])
try:
from rasterio.warp import transform as warp_transform
xs = []
ys = []
for t in tiles:
# SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000
xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000,
(t['col'] + 1) * 1000, t['col'] * 1000])
ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000,
t['row'] * 1000, t['row'] * 1000])
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
ok = True
except ImportError:
try:
from pyproj import Transformer
transformer = Transformer.from_crs('EPSG:2154', 'EPSG:4326',
always_xy=True)
lons, lats = transformer.transform(xs, ys)
ok = True
except ImportError:
logger.debug("Coins GPS approximatifs (rasterio et pyproj indisponibles)")
lons = None
lats = None
ok = False
except Exception as e:
logger.debug(f"Coins GPS approximatifs (rasterio indisponible: {e})")
lons = None
@ -555,6 +606,19 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
return usable
def _viz_src_dir(tile, info):
"""Dossier réel du fichier d'une visualisation.
Les couches fusionnées d'une autre résolution (run interrompu entre les
deux passes) vivent dans leur dossier d'origine — info['dir_name'] — et
non dans dir_path de la dalle affichée.
"""
d = info.get('dir_name') or tile.get('dir_name')
if d and d != Path(tile['dir_path']).name:
return Path(tile['dir_path']).parent / d
return Path(tile['dir_path'])
def _collect_tile_metadata(tile, dtm_dir):
"""Rassemble les métadonnées de génération d'une tuile.
@ -587,7 +651,8 @@ def _collect_tile_metadata(tile, dtm_dir):
for viz_key, info in tile['viz'].items():
try:
st = (Path(tile['dir_path']) / info['filename']).stat()
viz_dir = _viz_src_dir(tile, info)
st = (viz_dir / info['filename']).stat()
meta['viz'][viz_key] = {
'date': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'),
'size': st.st_size,
@ -643,12 +708,15 @@ def build_index(output_dir, output_format='avif'):
# visualisations produites uniquement à l'autre résolution : sans cela,
# une couche en cours de génération (passe 0,5 m faite, 0,2 m pas encore)
# reste invisible sur la carte et absente du panneau de couches.
# dir_name mémorise le dossier d'origine : vignettes, URLs et métadonnées
# doivent lire le fichier là où il existe réellement.
for key, best in best_by_pos.items():
for other in tiles_by_pos[key]:
if other is best:
continue
for viz_key, viz_info in other['viz'].items():
best['viz'].setdefault(viz_key, viz_info)
if viz_key not in best['viz']:
best['viz'][viz_key] = dict(viz_info, dir_name=other['dir_name'])
tiles = sorted(best_by_pos.values(),
key=lambda t: (t['resolution'], -t['row'], t['col']))
@ -686,7 +754,9 @@ def build_index(output_dir, output_format='avif'):
viz_thumbs = {}
regen = 0
for viz_key, info in t['viz'].items():
src = Path(t['dir_path']) / info['filename']
# Les couches fusionnées d'une autre résolution vivent dans
# leur dossier d'origine (info['dir_name']), pas dir_path.
src = _viz_src_dir(t, info) / info['filename']
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
thumb_path = thumb_dir / thumb_name
# Régénère si manquante ou périmée (tuile recalculée depuis)
@ -699,9 +769,10 @@ def build_index(output_dir, output_format='avif'):
continue
else:
thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{t['dir_name']}/{info['filename']}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
}
if regen:
@ -759,6 +830,16 @@ 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)")
# 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,
'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz},
'stats': {'default_viz': default_viz, 'n_tiles': len(display_tiles)},
}, ensure_ascii=False), encoding='utf-8')
_write_assets(output_dir)
html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format)
html_path = output_dir / 'index.html'
@ -781,8 +862,12 @@ def _write_assets(output_dir):
(assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8')
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
def _panel_selection(all_viz_keys):
"""Couches proposées dans le panneau + couche activée par défaut.
Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit
décrire le panneau exactement comme la page au moment du rebuild).
"""
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
# les autres visualisations existent sur disque mais ne sont pas proposées.
panel_keys = set(all_viz_keys)
@ -793,24 +878,35 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
for v in sorted(panel_keys):
if v not in ordered_viz:
ordered_viz.append(v)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
return ordered_viz, default_viz
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""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.dumps(tiles, ensure_ascii=False)
# Options du sélecteur de visualisations de la barre de génération.
# Options du sélecteur de génération/régénération : uniquement les couches
# réellement proposées dans le panneau de visualisation (mêmes couches,
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Repli sur
# aspect si aucune couche n'est encore disponible.
gen_keys = ordered_viz if ordered_viz else ['aspect']
gen_viz_options = "\n".join(
f' <option value="{name}"{" selected" if name == "aspect" else ""}>'
f'{label}</option>'
for name, label in GEN_VIZ_CHOICES)
f' <option value="{KEYWORD_TO_STEP.get(v, v)}"'
f'{" selected" if v == "aspect" else ""}>'
f'{VIZ_LABELS.get(v, v)}</option>'
for v in gen_keys)
viz_meta_json = json.dumps({
k: {'label': VIZ_LABELS.get(k, k)}
for k in ordered_viz
}, ensure_ascii=False)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
ensure_ascii=False)
@ -850,6 +946,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<nav id="mapTools" class="panel">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
@ -955,6 +1052,8 @@ _APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */
--accent: #5aa9e6;
--accent-dim: #2e7cc4;
--danger: #e94560;
--success: #4cc98a;
--success-dim: #2f8f63;
--radius: 10px;
}
@ -1020,6 +1119,13 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; }
#mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); }
#mapTools #btnGenZone:hover { background: var(--accent); }
#mapTools #btnComplete {
width: auto; padding: 0 14px; font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em;
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
box-shadow: 0 2px 10px rgba(76, 201, 138, 0.25);
}
#mapTools #btnComplete:hover { background: var(--success); border-color: var(--success); box-shadow: 0 2px 14px rgba(76, 201, 138, 0.45); }
#mapTools #btnComplete .ok-ic { margin-right: 6px; font-weight: 700; }
/* --- Panneau de couches (gauche) --- */
#layerPanel { top: 74px; left: 12px; width: 300px; }
@ -1150,10 +1256,15 @@ input[type="range"]::-moz-range-thumb {
font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
}
/* File de génération : panneau latéral, le journal replié par défaut */
#genQueue { bottom: 48px; right: 12px; width: 780px; max-width: 94vw; }
/* File de génération : panneau latéral — peut occuper toute la hauteur
de l'écran si besoin (plafonné au viewport, scroll interne au-delà) */
#genQueue {
bottom: 48px; right: 12px; width: 780px; max-width: 94vw;
max-height: calc(100vh - 60px);
display: flex; flex-direction: column;
}
#genQueueBody {
padding: 8px 10px; max-height: 42vh; overflow-y: auto;
padding: 8px 10px; flex: 1; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column; gap: 8px;
}
.gen-queue-actions { display: flex; align-items: center; gap: 8px; }
@ -1189,6 +1300,40 @@ input[type="range"]::-moz-range-thumb {
color: #9fb3d9; white-space: pre-wrap;
}
/* Tuiles une par une : nom court + puces d'étapes (types de visualisation).
Sans plafond : c'est le corps du panneau qui scrolle si trop long. */
.gen-task-tiles {
margin: 0 8px 8px;
display: flex; flex-direction: column; gap: 4px;
}
.gen-tile {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--panel-border); border-radius: 6px;
padding: 4px 8px; font-size: 11.5px;
}
.gen-tile-name {
min-width: 78px; flex: none; font-variant-numeric: tabular-nums;
color: var(--text); cursor: default;
}
.gen-tile-steps { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
.gen-step {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 7px; border-radius: 999px;
background: rgba(255, 255, 255, 0.05); color: var(--muted);
font-size: 10.5px; white-space: nowrap;
}
.gen-step::before { content: '·'; font-weight: bold; }
.gen-step.running { color: var(--accent); background: rgba(90, 169, 230, 0.12); }
.gen-step.running::before { content: '⟳'; animation: gen-pulse 1.1s ease-in-out infinite; }
.gen-step.ok { color: #39d98a; background: rgba(57, 217, 138, 0.10); }
.gen-step.ok::before { content: '✓'; }
.gen-step.fail { color: var(--danger); background: rgba(233, 69, 96, 0.12); }
.gen-step.fail::before { content: '✗'; }
.gen-step.skip::before { content: '–'; }
.gen-tile-state { flex: none; font-size: 11px; }
.gen-tile.done .gen-tile-state { color: #39d98a; }
.gen-tile.failed .gen-tile-state { color: var(--danger); }
body.drawing, body.drawing #viewport { cursor: crosshair !important; }
/* --- Responsive --- */
@ -1289,8 +1434,10 @@ let gridBounds = null;
const markers = new Map();
const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0);
for (const t of TILES) {
if (!t.corners) continue;
// Crée le marqueur Leaflet d'une tuile (utilisé à l'init et en direct,
// quand une tuile terminée apparaît en cours de génération).
function registerTile(t) {
if (!t.corners) return;
const center = [(t.corners[0][0] + t.corners[2][0]) / 2,
(t.corners[0][1] + t.corners[2][1]) / 2];
const icon = L.divIcon({
@ -1305,10 +1452,20 @@ for (const t of TILES) {
}).addTo(map);
mk.on('click', () => { if (!genMode) showTileInfo(t); });
markers.set(tileKey(t), mk);
const b = L.latLngBounds(t.corners);
gridBounds = gridBounds ? gridBounds.extend(b) : b;
}
function recomputeGridBounds() {
gridBounds = null;
for (const t of TILES) {
if (!t.corners) continue;
const b = L.latLngBounds(t.corners);
gridBounds = gridBounds ? gridBounds.extend(b) : b;
}
}
for (const t of TILES) registerTile(t);
recomputeGridBounds();
function tileDiv(t) {
const mk = markers.get(tileKey(t));
const el = mk && mk.getElement();
@ -1666,6 +1823,7 @@ function regenerateCurrentTile() {
hideTileInfo();
pendingTiles = [[t.col, t.row]];
pendingRegen = true;
pendingCompleteAll = false;
document.getElementById('genRegen').checked = true;
genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez';
genGo.style.display = '';
@ -1677,6 +1835,7 @@ function regenerateCurrentTile() {
// ============================================================
let apiAvailable = false;
const genBtn = document.getElementById('btnGenZone');
const btnComplete = document.getElementById('btnComplete');
const genBar = document.getElementById('genBar');
const genMsg = document.getElementById('genMsg');
const genGo = document.getElementById('genGo');
@ -1691,16 +1850,24 @@ const genQueueBody = document.getElementById('genQueueBody');
const genQueueClear = document.getElementById('genQueueClear');
const genQueueCount = document.getElementById('genQueueCount');
let genMode = false, drawStart = null, drawRect = null;
let pendingTiles = [], pendingRegen = false;
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
const ghostCells = [];
fetch('/api/status').then(r => {
apiAvailable = r.ok;
if (r.ok) genBtn.hidden = false;
if (r.ok) { genBtn.hidden = false; btnComplete.hidden = false; }
}).catch(() => {});
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
// Visualisations choisies dans la barre de génération (noms d'étapes --only).
// Sert au preview (détection des tuiles incomplètes) et au lancement : une
// tuile existante mais privée d'une de ces visualisations est incluse.
function selectedGenViz() {
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
return sel.length ? sel : ['aspect'];
}
function exitGenMode() {
genMode = false; drawStart = null;
document.body.classList.remove('drawing');
@ -1743,50 +1910,87 @@ map.on('mouseup', e => {
fetch('/api/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
regenerate: genRegen.checked })
regenerate: genRegen.checked,
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
if (drawRect) { drawRect.remove(); drawRect = null; }
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = false; // zone dessinée : le scope global est levé
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
));
if (!pendingTiles.length) {
genMsg.textContent = 'Aucune tuile manquante dans cette zone' +
(genRegen.checked ? '' : ' (cochez « Régénérer » pour traiter les tuiles existantes)');
genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' +
(genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)');
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à ' +
(genRegen.checked ? 'régénérer' : 'télécharger et générer') +
(genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') +
(d.capped ? ' (zone tronquée à 400)' : '');
genGo.style.display = '';
}).catch(() => { genMsg.textContent = 'API indisponible'; });
});
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
// moins une des visualisations choisies (pas de téléchargement) — la barre de
// confirmation reste utilisable (choix des couches, classification, Lancer).
btnComplete.addEventListener('click', () => {
if (!apiAvailable) return;
clearGhosts();
genBar.classList.add('show');
genMsg.textContent = 'Analyse des tuiles présentes dans input/...';
genGo.style.display = 'none';
fetch('/api/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all_missing: true, regenerate: genRegen.checked,
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = true;
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
));
if (!pendingTiles.length) {
genMsg.textContent = 'Aucune visualisation manquante pour les couches choisies';
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à compléter (dalles présentes dans input/)' +
(d.capped ? ' (liste tronquée)' : '');
genGo.style.display = '';
}).catch(() => { genMsg.textContent = 'API indisponible'; });
});
genGo.addEventListener('click', () => {
if (!pendingTiles.length) return;
genBar.classList.remove('show');
clearGhosts();
const nTiles = pendingTiles.length;
const isRegen = genRegen.checked || pendingRegen;
const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
fetch('/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: pendingTiles,
regenerate: isRegen,
viz: vizSel.length ? vizSel : ['aspect'],
viz: selectedGenViz(),
all_missing: pendingCompleteAll,
ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
bare_earth: genBareEarth ? genBareEarth.checked : false })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => startGenTask(nTiles + ' tuile(s) — ' + (isRegen ? 'régénération' : 'génération')))
.then(() => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
return startGenTask(nTiles + ' tuile(s) — ' + verb);
})
.catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); });
});
genCancel.addEventListener('click', () => {
genBar.classList.remove('show');
clearGhosts();
pendingTiles = []; pendingRegen = false;
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
if (genMode) exitGenMode();
});
@ -1794,16 +1998,45 @@ genCancel.addEventListener('click', () => {
// File de génération : panneau latéral persistant (sessionStorage).
// Les tâches survivent au rechargement de la carte : une tâche finie
// reste affichée « Terminé » jusqu'au vidage manuel de la liste.
// Chaque tâche montre ses tuiles une par une (nom court + types
// d'étapes) ; le journal brut reste accessible via le bouton ⌄.
// ============================================================
const GEN_QKEY = 'lidarGenQueue';
let genTasks = [];
try { genTasks = JSON.parse(sessionStorage.getItem(GEN_QKEY) || '[]'); } catch (e) { genTasks = []; }
// Migration des tâches créées avant l'affichage par tuiles : sans champ
// tiles, le journal brut pouvait rester ouvert d'office — on le replie.
genTasks.forEach(t => { if (!Array.isArray(t.tiles)) { t.tiles = []; t.logOpen = false; } });
let genPollTimer = null;
function saveGenTasks() {
try { sessionStorage.setItem(GEN_QKEY, JSON.stringify(genTasks)); } catch (e) { /* privé */ }
}
function genStepEl(s) {
const el = document.createElement('span');
el.className = 'gen-step ' + s.state;
el.textContent = s.label;
if (s.state === 'skip') el.title = 'Déjà existant — ignoré';
return el;
}
function genTileEl(t) {
const el = document.createElement('div');
el.className = 'gen-tile ' + t.state;
const name = document.createElement('span');
name.className = 'gen-tile-name';
name.textContent = t.short; name.title = t.name;
const steps = document.createElement('span');
steps.className = 'gen-tile-steps';
(t.steps || []).forEach(s => steps.appendChild(genStepEl(s)));
const state = document.createElement('span');
state.className = 'gen-tile-state';
state.textContent = t.state === 'done' ? '✓' : t.state === 'failed' ? '✗' : '';
el.append(name, steps, state);
return el;
}
function genTaskEl(t) {
const el = document.createElement('div');
el.className = 'gen-task ' + t.state;
@ -1814,17 +2047,35 @@ function genTaskEl(t) {
label.className = 'gen-task-label';
label.title = t.label; label.textContent = t.label;
const state = document.createElement('span'); state.className = 'gen-task-state';
state.textContent = t.state === 'running' ? 'En cours…'
: t.state === 'done' ? 'Terminé'
: 'Échec (code ' + t.returncode + ')';
const tiles = t.tiles || [];
if (t.state === 'running') {
const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length;
state.textContent = tiles.length
? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)'
: 'En cours…';
} else if (t.state === 'done') {
state.textContent = 'Terminé';
} else {
state.textContent = 'Échec (code ' + t.returncode + ')';
}
const time = document.createElement('span');
time.className = 'gen-task-time'; time.textContent = t.time;
const toggle = document.createElement('button');
toggle.className = 'icon-btn gen-task-toggle';
toggle.textContent = t.logOpen ? '⌃' : '⌄';
toggle.title = 'Afficher / masquer le journal';
toggle.title = 'Afficher / masquer le journal brut';
head.append(dot, label, state, time, toggle);
el.appendChild(head);
// Corps : tuiles une par une (nom + types d'étapes), en cours et échecs
// en tête, terminées en dessous — le journal brut reste replié sous ⌄.
if (tiles.length) {
const list = document.createElement('div');
list.className = 'gen-task-tiles';
const rank = s => s === 'running' ? 0 : s === 'failed' ? 1 : s === 'done' ? 3 : 2;
[...tiles].sort((a, b) => rank(a.state) - rank(b.state))
.forEach(x => list.appendChild(genTileEl(x)));
el.appendChild(list);
}
const log = document.createElement('pre');
log.className = 'gen-task-log';
log.hidden = !t.logOpen;
@ -1853,7 +2104,7 @@ function startGenTask(labelText) {
genTasks.push({
id: Date.now(), label: labelText,
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
state: 'running', returncode: null, log: [], logOpen: false,
state: 'running', returncode: null, log: [], logOpen: false, tiles: [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
}
@ -1870,17 +2121,98 @@ function finishRunningTasks(d) {
renderGenQueue();
}
// ============================================================
// Tuiles en direct : chaque tuile terminée apparaît sans recharger
// ============================================================
// Pendant un run, le pipeline régénère l'index après chaque tuile terminée
// (--incremental-index) et la webapp sert ces données via /api/tiles. On
// fusionne les nouveautés dans la carte : la tuile est visible dès la fin
// de son rendu, sans attendre la fin de la file.
let liveTilesStamp = null;
function mergeTilesData(data) {
if (!data || !Array.isArray(data.tiles)) return;
const byKey = new Map();
for (const t of data.tiles) {
if (t.corners) byKey.set(tileKey(t), t);
}
let changed = false;
// Retire les tuiles disparues (ex. dalle 0,5 m remplacée par ses
// sous-tuiles 0,2 m plus fines)
for (let i = TILES.length - 1; i >= 0; i--) {
const key = tileKey(TILES[i]);
if (byKey.has(key)) continue;
const mk = markers.get(key);
if (mk) { mk.remove(); markers.delete(key); }
TILES.splice(i, 1);
changed = true;
}
byKey.forEach((t, key) => {
const cur = TILES.find(x => tileKey(x) === key);
if (!cur) {
TILES.push(t);
registerTile(t);
changed = true;
} else if (cur !== t) {
Object.assign(cur, t); // tuile enrichie (nouvelles couches en cours de run)
changed = true;
}
});
// Couches nouvellement proposées : ajoutées au panneau, éteintes par défaut
let metaChanged = false;
if (data.viz_meta) {
for (const k of Object.keys(data.viz_meta)) {
if (VIZ_META[k]) continue;
VIZ_META[k] = data.viz_meta[k];
if (!state.layers.order.includes(k)) {
state.layers.order.push(k);
state.layers.on[k] = false;
state.layers.opacity[k] = 1;
}
metaChanged = true;
}
}
if (changed) {
recomputeGridBounds();
const statTiles = document.getElementById('statTiles');
if (statTiles) {
const cols = [...new Set(TILES.map(t => t.col))].sort((a, b) => a - b);
const rows = [...new Set(TILES.map(t => t.row))].sort((a, b) => a - b);
const gw = cols.length ? cols[cols.length - 1] - cols[0] + 1 : 0;
const gh = rows.length ? rows[rows.length - 1] - rows[0] + 1 : 0;
statTiles.textContent = TILES.length + ' tuile(s) · ' + gw + '×' + gh + ' km · ' +
statTiles.textContent.split('·').pop().trim();
}
}
if (changed || metaChanged) renderLayerPanel();
if (changed) { syncTileLayers(); layoutTiles(); }
}
function pollLiveTiles() {
const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : '';
fetch('/api/tiles' + q).then(r => r.ok ? r.json() : null).then(d => {
if (!d) return;
liveTilesStamp = d.stamp;
if (d.tiles) mergeTilesData(d);
}).catch(() => {});
}
function pollGenStatus() {
if (genPollTimer) return; // un seul sondage global pour toute la file
genPollTimer = setInterval(() => {
fetch('/api/status').then(r => r.json()).then(d => {
// Réponse d'erreur (ex: machine de traitement injoignable) : ignorer,
// le sondage reprendra — ne pas marquer les tâches en échec.
if (typeof d.running !== 'boolean') return;
const running = genTasks.filter(t => t.state === 'running');
if (!running.length) {
clearInterval(genPollTimer); genPollTimer = null;
return;
}
const lines = d.log || [];
for (const t of running) t.log = lines.slice(-60);
for (const t of running) {
t.log = lines.slice(-60);
// Tuiles une par une (nom court + types d'étapes) depuis /api/status
if (d.tiles && d.tiles.length) t.tiles = d.tiles;
}
if (!d.running) {
clearInterval(genPollTimer); genPollTimer = null;
finishRunningTasks(d);
@ -1890,14 +2222,33 @@ function pollGenStatus() {
const c = map.getCenter();
sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
} catch (e) { /* sessionStorage indisponible : pas grave */ }
setTimeout(() => location.reload(), 2500);
syncThenReload();
} else {
saveGenTasks(); renderGenQueue();
pollLiveTiles();
}
}).catch(() => {});
}, 2000);
}
// Après un run : ramener les tuiles produites par la machine de traitement
// (rsync via /api/sync — mode webapp sur Raspberry Pi), reconstruire vignettes
// et carte, puis recharger. Sans backend distant, /api/sync se réduit à un
// rebuild local : même flux partout. Rechargement de secours après 30 min.
function syncThenReload() {
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
const t0 = Date.now();
const check = setInterval(() => {
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) {
clearInterval(check);
location.reload();
}
}).catch(() => { clearInterval(check); location.reload(); });
}, 2000);
}).catch(() => location.reload());
}
genQueueClear.addEventListener('click', () => {
genTasks = genTasks.filter(t => t.state === 'running');
saveGenTasks(); renderGenQueue();
@ -1905,17 +2256,30 @@ genQueueClear.addEventListener('click', () => {
renderGenQueue();
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
// ou adopte une génération lancée hors interface (API directe) pour
// l'afficher dans la file avec son journal.
// adopte une génération lancée hors interface (API directe), ou réaffiche le
// dernier run terminé après un redémarrage du serveur (état rechargé du
// fichier .generation.job.json). La clé `started` évite les doublons au
// rechargement : un même run n'est adopté qu'une fois.
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
if (!s || !s.running) return;
genTasks.push({
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
state: 'running', returncode: null, log: [], logOpen: false,
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
if (!s || genTasks.some(t => t.started === s.started)) return;
const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
if (s.running) {
genTasks.push({
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
time: now, started: s.started,
state: 'running', returncode: null, log: [], logOpen: false, tiles: s.tiles || [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
} else if (s.returncode !== null && s.tiles && s.tiles.length) {
genTasks.push({
id: Date.now(), label: 'Dernière génération',
time: now, started: s.started,
state: s.returncode === 0 ? 'done' : 'failed', returncode: s.returncode,
log: [], logOpen: false, tiles: s.tiles,
});
saveGenTasks(); renderGenQueue();
}
}).catch(() => {});
// ============================================================
@ -1932,8 +2296,8 @@ zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
// Détection de nouvelles couches (run de génération en cours)
// ============================================================
// Le panneau et les URLs par tuile sont figés au rebuild de l'index :
// /api/layers liste ce qui existe sur disque, et le bouton ↻ reconstruit
// la carte (vignettes + sous-tuiles) puis recharge la page.
// /api/layers liste ce qui existe sur disque, et le bouton ↻ resynchronise
// puis reconstruit la carte (vignettes + sous-tuiles) et recharge la page.
const layerRefreshBtn = document.getElementById('layerRefresh');
function checkNewLayers() {
fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => {
@ -1951,11 +2315,11 @@ setInterval(checkNewLayers, 60000);
if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
layerRefreshBtn.textContent = '…';
layerRefreshBtn.disabled = true;
fetch('/api/rebuild', { method: 'POST' })
fetch('/api/sync', { method: 'POST' })
.then(r => r.ok ? r.json() : Promise.reject('rejeté'))
.then(() => new Promise(resolve => {
const poll = setInterval(() => {
fetch('/api/rebuild').then(r => r.json()).then(s => {
fetch('/api/sync').then(r => r.json()).then(s => {
if (!s.running) { clearInterval(poll); resolve(s.error); }
}).catch(() => { clearInterval(poll); resolve(null); });
}, 3000);
@ -1964,7 +2328,7 @@ if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
if (err) {
layerRefreshBtn.textContent = '↻';
layerRefreshBtn.disabled = false;
layerRefreshBtn.title = 'Erreur rebuild : ' + err;
layerRefreshBtn.title = 'Erreur synchronisation/rebuild : ' + err;
} else {
location.reload();
}