Afficher le contour de dalle au-dessus des tuiles et ajouter un compteur de tuiles restantes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 13:18:42 +02:00
parent 3d415de86c
commit 6e1b26986c
2 changed files with 101 additions and 3 deletions

View File

@ -147,6 +147,11 @@ _MAP_HTML = """<!DOCTYPE html>
<button id="gps" title="Centrer sur ma position">⌖</button> <button id="gps" title="Centrer sur ma position">⌖</button>
</nav> </nav>
<div id="toast" class="toast" hidden></div> <div id="toast" class="toast" hidden></div>
<div id="tileload" class="tileload" hidden aria-live="polite">
<span class="tl-spin"></span>
<span id="tileloadText"></span>
<span class="tl-bar"><span id="tileloadBar"></span></span>
</div>
<script src="assets/vendor/leaflet/leaflet.js"></script> <script src="assets/vendor/leaflet/leaflet.js"></script>
<script src="assets/app.js?v=__ASSETS_V__"></script> <script src="assets/app.js?v=__ASSETS_V__"></script>
@ -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); #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); } 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); } #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; .toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
background: var(--card); border: 1px solid var(--border); border-radius: 8px; 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; } 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', { const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark', maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
// Sinon _setView recrée un niveau en cours d'animation malgré noUpdate. // Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
@ -833,8 +911,11 @@ function kv(rows) {
function selectTileOutline(data) { function selectTileOutline(data) {
if (tileSel) { map.removeLayer(tileSel); tileSel = null; } if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
if (!data || !data.corners) return; if (!data || !data.corners) return;
tileSel = L.polygon(data.corners, { color: '#ffd24a', weight: 2, fill: false, // Panneau dédié au-dessus de la pile LiDAR : dans l'overlayPane par défaut
dashArray: '6 4', interactive: false }).addTo(map); // (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) { function ignSection(ign) {
@ -1033,7 +1114,7 @@ let genDrawing = false, genDrawStart = null, genDrawRect = null;
let genIgnoreClickUntil = 0; let genIgnoreClickUntil = 0;
let lastTileQuery = null; // latlng du dernier clic (fiche dalle) 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); map.createPane(pane).style.zIndex = String(z);
} }

View File

@ -283,6 +283,23 @@ def test_ui_offers_reorder_and_blend_modes():
assert "const b = validBlend(f[3]);" in _MAP_JS 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): def test_defaults_roundtrip(tmp_path, monkeypatch):
"""L'état réglé depuis la carte devient la configuration servie à tous.""" """L'état réglé depuis la carte devient la configuration servie à tous."""
import lidar_pipeline.mapserve as mapserve import lidar_pipeline.mapserve as mapserve