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:
@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user