Add Leaflet interactive map, tile generator compose, auto-sync cache, deploy docs

index.py rewritten as a continuous Leaflet map: rotated L93 tiles, stackable
visualization layers (per-layer opacity, drag-reorder persisted in
localStorage), tile info panel, live rebuild after each tile during a run.
Leaflet is vendored in assets/vendor/ so the map works fully offline;
georeferencing falls back rasterio -> pyproj -> affine so the lightweight
webapp (no GDAL) is supported.

docker-compose.worker.yml adds the tile generator service (full image + GPU)
that remote webapps call via LIDAR_GENERATION_URL, plus a one-shot process
profile. webapp.py gains LIDAR_AUTO_SYNC_SECONDS periodic cache refresh and
LIDAR_REGEN_CIDR restricting generation to the local network. run.sh
--serve-webapp now mounts ~/.ssh read-only so the rsync sync works.

docs/DEPLOY_WEBAPP.md completed for Raspberry Pi deployment: prerequisites,
git clone install, SSH key setup, first sync, update procedure and
troubleshooting.
This commit is contained in:
Antoine Jacquin
2026-09-04 21:36:14 +02:00
parent 422f58d772
commit 23969c9e14
17 changed files with 1828 additions and 336 deletions

View File

@ -257,14 +257,29 @@ def test_build_index_generates_html(tmp_path):
assert "t.started === s.started" in app_js
# Bouton « Compléter » : visualisations manquantes des dalles présentes
# dans input/ (scope all_missing envoyé au preview et au generate).
# « + Zone » et « Compléter » ne vivent que dans l'onglet Génération,
# jamais dans la barre d'outils flottante de la carte.
assert 'id="btnComplete"' in content
map_tools = content.split('id="mapTools"', 1)[1].split('</nav>', 1)[0]
assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools
tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0]
assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
# Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs
# Leaflet, pane 600) pour rectangle et cellules fantômes.
assert "createPane('genDraw')" in app_js
assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules
assert 'assets/app.css' in content
# Bouton d'actualisation des couches (détection des nouveautés sur disque) :
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/sync" in app_js
assert 'assets/app.js' in content
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
assert 'assets/vendor/leaflet/leaflet.js' in content
assert 'assets/vendor/leaflet/leaflet.css' in content
assert 'unpkg.com' not in content
assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file()
# Fin d'un run de génération : synchronisation puis rechargement (le
# rechargement direct reste en secours si /api/sync est absent).
assert "syncThenReload" in app_js
@ -284,6 +299,23 @@ def test_build_index_generates_html(tmp_path):
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
def test_mid_thumbnails_generated(tmp_path):
"""Vignette intermédiaire 640 px générée et référencée dans l'index."""
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',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
viz = data['tiles'][0]['viz']['hillshade_multi']
assert 'mid' in viz
assert (output_dir / viz['mid']).exists()
assert viz['mid'].endswith("_mid.jpg")
def test_subtiled_viz_covers_generation_choices():
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
@ -387,6 +419,8 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif"
aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif"
assert hill_avif.exists() and aspect_avif.exists()
hill_mid = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0_mid.webp"
assert hill_mid.exists()
m_hill_1 = hill_avif.stat().st_mtime
m_aspect_1 = aspect_avif.stat().st_mtime