diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py
index 6cdf4a7..6bec269 100644
--- a/lidar_pipeline/mapui.py
+++ b/lidar_pipeline/mapui.py
@@ -147,6 +147,11 @@ _MAP_HTML = """
+
+
+
+
+
@@ -280,6 +285,23 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
#tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card);
border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
#gps.busy { color: var(--accent); border-color: var(--accent); }
+/* Indicateur de chargement : tuiles restantes pour finir la vue courante
+ (fond + LiDAR), barre de progression du lot en cours. */
+.tileload { position: fixed; left: 50%; top: 12px; transform: translateX(-50%); z-index: 905;
+ display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: 999px;
+ background: var(--card); border: 1px solid var(--border); font-size: 12px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); pointer-events: none;
+ transition: opacity 0.3s ease; white-space: nowrap; }
+.tileload.done { opacity: 0; }
+.tl-spin { width: 12px; height: 12px; border-radius: 50%; flex: none;
+ border: 2px solid rgba(90, 169, 230, 0.25); border-top-color: var(--accent);
+ animation: tlspin 0.8s linear infinite; }
+.tileload.done .tl-spin { animation: none; border-color: var(--accent); }
+@keyframes tlspin { to { transform: rotate(360deg); } }
+.tl-bar { width: 60px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.1);
+ overflow: hidden; flex: none; }
+.tl-bar span { display: block; height: 100%; width: 0; background: var(--accent);
+ transition: width 0.2s ease; }
.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; }
@@ -412,6 +434,62 @@ const ZoomAnimTileLayer = L.TileLayer.extend({
}
});
+// --- chargement des tuiles -------------------------------------------------
+// Compteur recalculé depuis l'état réel des tuiles de chaque couche (tuiles
+// du niveau courant pas encore chargées) plutôt que par +1/−1 sur les
+// événements : une tuile retirée avant la fin de son chargement (déplacement
+// rapide) ne déclenche ni « tileload » ni « tileerror », un compteur par
+// événements dériverait. Lot : de la première tuile en attente au retour à
+// zéro ; la barre donne la part déjà chargée du lot.
+const TILE_LOAD = { layers: new Set(), batch: 0, done: 0, raf: 0, hideT: 0 };
+
+function tilesPending() {
+ let n = 0;
+ for (const layer of TILE_LOAD.layers) {
+ if (!layer._map) continue;
+ for (const key in layer._tiles) {
+ const t = layer._tiles[key];
+ if (t.current && !t.loaded) n += 1;
+ }
+ }
+ return n;
+}
+
+function updateTileLoad() {
+ TILE_LOAD.raf = 0;
+ const box = el('tileload');
+ if (!box) return;
+ const pending = tilesPending();
+ if (pending > 0) {
+ clearTimeout(TILE_LOAD.hideT);
+ TILE_LOAD.hideT = 0;
+ if (TILE_LOAD.batch === 0) TILE_LOAD.done = 0;
+ TILE_LOAD.batch = Math.max(TILE_LOAD.batch, TILE_LOAD.done + pending);
+ el('tileloadText').textContent = pending + (pending > 1 ? ' tuiles restantes' : ' tuile restante');
+ el('tileloadBar').style.width = Math.round(100 * TILE_LOAD.done / TILE_LOAD.batch) + '%';
+ box.classList.remove('done');
+ box.hidden = false;
+ } else if (TILE_LOAD.batch > 0 && !TILE_LOAD.hideT) {
+ TILE_LOAD.batch = 0;
+ el('tileloadText').textContent = 'Vue chargée';
+ el('tileloadBar').style.width = '100%';
+ box.classList.add('done');
+ TILE_LOAD.hideT = setTimeout(() => { box.hidden = true; TILE_LOAD.hideT = 0; }, 600);
+ }
+}
+
+function scheduleTileLoad() {
+ if (!TILE_LOAD.raf) TILE_LOAD.raf = requestAnimationFrame(updateTileLoad);
+}
+
+ZoomAnimTileLayer.addInitHook(function () {
+ TILE_LOAD.layers.add(this);
+ const onDone = () => { TILE_LOAD.done += 1; scheduleTileLoad(); };
+ this.on('tileload', onDone);
+ this.on('tileerror', onDone);
+ this.on('tileloadstart tileunload load add remove', scheduleTileLoad);
+});
+
const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
// Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
@@ -833,8 +911,11 @@ function kv(rows) {
function selectTileOutline(data) {
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
if (!data || !data.corners) return;
- tileSel = L.polygon(data.corners, { color: '#ffd24a', weight: 2, fill: false,
- dashArray: '6 4', interactive: false }).addTo(map);
+ // Panneau dédié au-dessus de la pile LiDAR : dans l'overlayPane par défaut
+ // (z-index 400, comme lidarStack), le contour passait sous les tuiles.
+ tileSel = L.polygon(data.corners, { pane: 'tileSel', color: '#ffd24a', weight: 2,
+ fill: false, dashArray: '6 4',
+ interactive: false }).addTo(map);
}
function ignSection(ign) {
@@ -1033,7 +1114,7 @@ let genDrawing = false, genDrawStart = null, genDrawRect = null;
let genIgnoreClickUntil = 0;
let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
-for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670]]) {
+for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670], ['tileSel', 680]]) {
map.createPane(pane).style.zIndex = String(z);
}
diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py
index 1887d0d..62a68a9 100644
--- a/lidar_pipeline/tests/test_mapserve.py
+++ b/lidar_pipeline/tests/test_mapserve.py
@@ -283,6 +283,23 @@ def test_ui_offers_reorder_and_blend_modes():
assert "const b = validBlend(f[3]);" in _MAP_JS
+def test_map_ui_tile_load_indicator_and_selection_pane():
+ """Chargement : compteur de tuiles restantes + contour de dalle visible.
+
+ Le compteur lit l'état réel des tuiles (une tuile retirée en cours de
+ chargement n'émet aucun événement de fin). Le contour de sélection vit
+ dans un panneau au-dessus de la pile LiDAR (z-index 400), sinon les
+ tuiles le masquent.
+ """
+ from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
+ assert 'id="tileload"' in _MAP_HTML and 'id="tileloadText"' in _MAP_HTML
+ assert "@keyframes tlspin" in _MAP_CSS
+ assert "ZoomAnimTileLayer.addInitHook" in _MAP_JS
+ assert "t.current && !t.loaded" in _MAP_JS
+ assert "['tileSel', 680]" in _MAP_JS
+ assert "pane: 'tileSel'" in _MAP_JS
+
+
def test_defaults_roundtrip(tmp_path, monkeypatch):
"""L'état réglé depuis la carte devient la configuration servie à tous."""
import lidar_pipeline.mapserve as mapserve