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