Corriger la revue finale : onglets non tronqués, fiche de dalle à jour, clics et lien fiables

Onglet Dalle libellé fixe (col·row dans le title), Affichage/Export PDF/
Génération raccourcis pour tenir dans le panneau ; fiche de dalle rechargée
après un run sans désélection et dès que /api/status révèle le bouton
Régénérer ; double-clic et réponses en retard ignorés sur la sélection ;
curseur d'intensité visible hors mode précision ; lien complet sans &I remis
à 1× ; applyCompareClip sort tôt hors mode Comparer ; docs/MAPS.md et
AGENTS.md à jour (modes Comparer, &I=, « Comment lire »).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 20:56:19 +02:00
parent c10e34f3b4
commit bae16923fc
5 changed files with 171 additions and 33 deletions

View File

@ -20,7 +20,7 @@
## Conventions
- **Un seul serveur web : `mapserve.py`** (image `lidar-maps`, port 8975 léger / 8973 worker). L'ancienne webapp (`webapp.py`, `export.py`, index.html/`_APP_JS`) a été supprimée : la génération de tuiles (portée de la webapp historique — `/api/preview`, `/api/generate`, `/api/status`, `/api/stop`, `/api/queue/clear`, `/api/cell`) vit dans `mapserve.py`, l'interface dans `web/map.{html,css,js}` (relus par `mapui.py`, constantes `_MAP_*` conservées, écrites par `write_map_assets()` et bâchées dans les images). Sur l'image légère sans `LIDAR_GENERATION_URL`, `/api/status` répond `available: false` et l'interface masque l'onglet Génération.
- **Interface en panneau unique** (`web/map.{html,css,js}`) : un seul panneau à onglets Affichage / Dalle / Export PDF / Génération (masqué si le générateur est indisponible ou non autorisé) / Partager, remplaçant l'ancienne pile de blocs empilés. Sous 720 px de large, le panneau devient un volet en bas d'écran à trois hauteurs (`closed`/`half`/`full`, poignée `#sheetGrip` glissée ou simplement touchée, ou onglet actif retouché). Un clic sur une dalle la **sélectionne** (contour) et remplit l'onglet Dalle (emprise, IGN, recalage des passes) sans changer l'onglet affiché ; re-cliquer la même dalle désélectionne, cliquer une autre déplace la sélection. Échap désélectionne la dalle puis replie le panneau (bande d'icônes sur ordinateur, volet fermé sur téléphone — la bande reste utilisable, un clic sur un onglet redéplie le panneau). Raccourcis clavier : 1–5 (onglets, sans effet si l'onglet est masqué), P (mode d'affichage suivant), Échap. Deux thèmes clair/sombre (`lidar-theme`, `auto` par défaut, suit le système) et tous les réglages communs posés sur `:root` en variables CSS (aucune couleur en dur dans les composants). Clés `localStorage` (via `lsGet`/`lsSet`, silencieux en navigation privée) : `lidarMapView_v2` (vue/couches), `lidar-print` (réglages d'export), `lidar-panel` (onglet, hauteur du volet, repli en bande d'icônes), `lidar-theme`.
- **Interface en panneau unique** (`web/map.{html,css,js}`) : un seul panneau à onglets Affichage / Dalle / Export PDF / Génération (masqué si le générateur est indisponible ou non autorisé) / Partager, remplaçant l'ancienne pile de blocs empilés. Sous 720 px de large, le panneau devient un volet en bas d'écran à trois hauteurs (`closed`/`half`/`full`, poignée `#sheetGrip` glissée ou simplement touchée, ou onglet actif retouché). Un clic sur une dalle la **sélectionne** (contour) et remplit l'onglet Dalle (emprise, IGN, recalage des passes) sans changer l'onglet affiché ; re-cliquer la même dalle désélectionne, cliquer une autre déplace la sélection. Échap désélectionne la dalle puis replie le panneau (bande d'icônes sur ordinateur, volet fermé sur téléphone — la bande reste utilisable, un clic sur un onglet redéplie le panneau). Raccourcis clavier : 1–5 (onglets, sans effet si l'onglet est masqué), P (mode d'affichage suivant), Échap. Intensité du relief (`&I=`, contraste 0,5–2×, défaut 1×) : réglage de confort personnel mémorisé dans `lidarMapView_v2.intensity` et partagé dans le lien, jamais figé comme défaut serveur (`★ Définir par défaut` l'ignore). Deux thèmes clair/sombre (`lidar-theme`, `auto` par défaut, suit le système) et tous les réglages communs posés sur `:root` en variables CSS (aucune couleur en dur dans les composants). Clés `localStorage` (via `lsGet`/`lsSet`, silencieux en navigation privée) : `lidarMapView_v2` (vue/couches), `lidar-print` (réglages d'export), `lidar-panel` (onglet, hauteur du volet, repli en bande d'icônes), `lidar-theme`.
- **`index.py` = catalogue + registres partagés** (plus d'interface) : `VIZ_LABELS`/`VIZ_LEGENDS`, défauts d'affichage (`DEFAULT_VIZ`/`PRECISION_VIZ`/`VIEW_MODES`), `PANEL_VIZ`/`KEYWORD_TO_STEP`, `scan_tiles`/`cells_with_all_viz`, vignettes + sous-tuiles + inventaire `index_tiles.json` (`build_index`). L'inventaire est servi par `/api/tiles` de mapserve aux machines légères (`LIDAR_SOURCE_URL`).
- **Generation is 0.2 m only** (policy): `/api/generate` (`GENERATE_RESOLUTIONS` in `mapserve.py`), the compose `process` command and the CLI `-r` default all produce 0.2 m exclusively; 0.5 m stays available via explicit `-r 0.5`. Completeness detection (`complete_cells`) requires the viz at 0.2 m only.
- **Génération du nord au sud** : les tuiles sont traitées par ligne décroissante (row = nord en km), colonnes croissantes — `find_laz_files` (pipeline.py) pour les passes batch et `_resolve_request` (mapserve.py) pour les runs lancés depuis la carte. Les workers prennent les fichiers dans l'ordre de soumission : la carte se remplit de haut en bas pendant un run (`--file` explicite au CLI = ordre utilisateur préservé). Parallélisme de génération : `LIDAR_WORKERS` (10 dans les compose ; `auto` sinon).

View File

@ -160,25 +160,39 @@ chacun, ou la touche **P** pour passer au suivant :
- **Précision** — la densité seule, en 16 gris (échelle log fixe : niveau k à
partir de 0,25 × 2^(k/2) pts/m², noir ≤ 0,35 ou aucun point, blanc ≥ 45) ;
se lit comme une carte de fiabilité géométrique ;
- **Les deux** — la précision en « produit » (`mix-blend-mode: multiply`) sur
le relief, opacité réglable (60 % par défaut) : les zones où le relief est
interpolé s'assombrissent sans masquer le relief.
- **Comparer** — une barre glissante (souris ou doigt) sépare deux couches
choisies dans deux menus (relief, précision ou fond OSM seul) de part et
d'autre du curseur ; la même couche des deux côtés n'y ajoute aucune
découpe.
L'onglet Affichage porte aussi une **intensité du relief** (curseur 0,5×–2×,
1× par défaut) : un contraste de confort posé sur le conteneur de la couche
affichée (`contrast()` CSS), mémorisé et partagé dans le lien (`&I=`, écrit
seulement si ≠ 1×) mais jamais figé comme défaut serveur ni appliqué au PDF
exporté (qui garde le rendu standard). Un bloc **Comment lire la carte**,
repliable, reprend pour la ou les couches affichées le texte de lecture de
`VIZ_LEGENDS` (aussi servi par `/api/map/meta` et le TileJSON).
La légende de la précision (16 paliers, info-bulle en pts/m² sur chaque
palier) s'affiche dès que la précision est visible. Les couches LiDAR vivent
dans un conteneur **isolé** (`isolation: isolate`) : le produit n'agit que sur
le relief, jamais sur le fond de carte.
palier) s'affiche dès que la précision est visible, seule ou d'un côté de la
barre Comparer. Les couches LiDAR vivent dans un conteneur **isolé**
(`isolation: isolate`) : la barre ne découpe que les calques LiDAR, jamais le
fond de carte.
Le lien de partage transporte la couche principale, le mode et l'opacité :
`#z/lat/lng&M=relief_oriente&P=both:60&B=1:85:1`. Les anciens liens de la pile
(`&L=…`) s'ouvrent sans erreur sur l'état courant.
Le lien de partage transporte la couche principale, le mode, la comparaison et
l'intensité :
`#z/lat/lng&M=relief_oriente&P=compare&C=relief:precision:30&I=1.4&B=1:85:1`
(`&C=gauche:droite:position%` seulement en mode Comparer). Les anciens liens
de la pile (`&L=…`) et de l'ancien mode « les deux » (`&P=both:opacité`,
opacité alors ignorée) s'ouvrent sans erreur, en mode relief.
### Figer la configuration
Le bouton **★ Définir par défaut** enregistre l'affichage courant — couche
principale, mode, opacité de la précision, fond de carte — dans
`output/.map-defaults.json`. Tout navigateur sans réglage local part alors de
cette configuration ; **↺ Réinitialiser** oublie l'état local et y revient.
principale, mode, fond de carte — dans `output/.map-defaults.json` (jamais
l'intensité, réglage de confort propre à chaque navigateur). Tout navigateur
sans réglage local part alors de cette configuration ; **↺ Réinitialiser**
oublie l'état local et y revient.
```bash
curl http://localhost:8975/api/map/defaults # configuration servie
@ -186,11 +200,10 @@ curl -X DELETE http://localhost:8975/api/map/defaults # retour au registre
```
Sans fichier enregistré, les défauts viennent du registre du pipeline
(`DEFAULT_VIZ`, `PRECISION_VIZ`, `DEFAULT_VIEW_MODE`,
`DEFAULT_PRECISION_OPACITY` dans `index.py`). Les valeurs reçues sont
filtrées : couche inconnue (ou la précision elle-même) refusée comme
principale, mode validé, opacités bornées à 0–1. Un fichier de l'ancienne pile
(`order`/`on`/`blend`) est ignoré, sauf le fond.
(`DEFAULT_VIZ`, `PRECISION_VIZ`, `DEFAULT_VIEW_MODE` dans `index.py`). Les
valeurs reçues sont filtrées : couche inconnue (ou la précision elle-même)
refusée comme principale, mode validé, opacité du fond bornée à 0–1. Un
fichier de l'ancienne pile (`order`/`on`/`blend`) est ignoré, sauf le fond.
## Export PDF (planche d'impression terrain)
@ -203,7 +216,7 @@ précédente si elle est encore visible), la carte zoome pour le montrer en
entier au-dessus du panneau, puis on navigue librement sans qu'il bouge. On le
déplace en faisant glisser sa **poignée ✥** (souris ou doigt) ; au relâchement,
la géométrie Lambert 93 exacte est recalculée (`GET /api/export/frame`) — un
relâchement suivi d'un clic immédiat n'ouvre pas la bulle de dalle (garde de
relâchement suivi d'un clic immédiat ne sélectionne pas de dalle (garde de
300 ms). « ⌖ Centrer ici » le ramène au centre de la vue, « ⤢ Voir le cadre »
zoome dessus ; changer de format, d'orientation ou d'échelle recadre la vue.
Les réglages et la position du cadre sont mémorisés dans `localStorage` du

View File

@ -1694,3 +1694,96 @@ def test_ui_panel_restores_tab_and_ignores_collapse_on_phone():
assert "PANEL.collapsed && !isPhone()" in render
assert "toggle('collapsed', PANEL.collapsed)" not in render
assert "toggle('panel-collapsed', PANEL.collapsed)" not in render
def test_ui_tile_tab_label_stays_dalle_col_row_in_title():
"""Revue finale (finding 2) : 5 onglets dans 340 px — le libellé de
l'onglet Dalle reste toujours « Dalle » (jamais tronqué), col · row va
dans le title de l'onglet (survol) et #tileTitle (déjà dans la fiche).
Affichage/Export PDF/Génération raccourcis pour tenir, Partager gardé."""
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
assert '<span class="tab-lbl">Vue</span>' in _MAP_HTML
assert '<span class="tab-lbl">PDF</span>' in _MAP_HTML
assert '<span class="tab-lbl">Générer</span>' in _MAP_HTML
assert '<span class="tab-lbl" id="tileTabLbl">Dalle</span>' in _MAP_HTML
assert 'data-tab="share"' in _MAP_HTML and '>Partager</span>' in _MAP_HTML
# Le title complet (survol) reste explicite malgré le libellé court
assert 'title="Affichage (1)"' in _MAP_HTML
assert 'title="Export PDF (3)"' in _MAP_HTML
assert 'title="Génération (4)"' in _MAP_HTML
assert "function tileTabTitle(" in _MAP_JS
assert "textContent = tileTabLabel" not in _MAP_JS # plus de libellé dynamique
assert "button[data-tab=\"tile\"]').title = tileTabTitle()" in _MAP_JS
def test_ui_tile_card_refetched_after_gen_run():
"""Finding 3 : la fiche de dalle sélectionnée avant/pendant un run devient
périmée (résolution, date, couches...) — genAfterRun la recharge par
api/map/tile sans jamais désélectionner (pas de clearTileSelection)."""
from lidar_pipeline.mapui import _MAP_JS
fn = _MAP_JS[_MAP_JS.index("function genAfterRun("):_MAP_JS.index("function genUpdateStatus(")]
assert "SELECTED_TILE" in fn and "lastTileQuery" in fn
assert "api/map/tile?lat=" in fn
assert "selectTile(d, lastTileQuery)" in fn
assert "clearTileSelection" not in fn
def test_ui_double_click_and_stale_click_response_ignored():
"""M1/M2 : un double-clic (zoom Leaflet, deux clics simples dont le
second a detail > 1) ne doit pas re-basculer la sélection posée par le
premier ; une réponse arrivée après un clic plus récent (jeton de
séquence) est ignorée plutôt que d'écraser l'état."""
from lidar_pipeline.mapui import _MAP_JS
click = _MAP_JS[_MAP_JS.index("map.on('click'"):]
body = click[:click.index("});") + 3]
assert "e.originalEvent && e.originalEvent.detail > 1" in body
assert "let tileClickSeq = 0;" in _MAP_JS
assert "const seq = ++tileClickSeq;" in body
assert "if (seq !== tileClickSeq) return;" in body
def test_ui_regen_button_appears_when_gen_status_resolves_late():
"""M3 : une dalle sélectionnée avant que /api/status ne réponde ne doit
pas rester sans bouton Régénérer — la fiche est ré-émise dès que GEN est
connu, dans le même bloc qui pose GEN.available/allowed."""
from lidar_pipeline.mapui import _MAP_JS
boot = _MAP_JS[_MAP_JS.index("fetch('api/map/meta').then(r => r.json()).then(meta => {"):]
status_block = boot[:boot.index("}).catch(() => {\n toast('Serveur de tuiles injoignable.'")]
assert "GEN.available = !!d.available;" in status_block
assert "if (SELECTED_TILE) renderTabTile();" in status_block
def test_ui_intensity_row_visible_outside_precision_block():
"""M4 (liaison de spec) : #intensityRow est un frère de #precBlock, pas un
enfant — visible dès l'onglet Affichage même sans couche de précision,
masqué seulement en mode précision (renderPanel)."""
from lidar_pipeline.mapui import _MAP_HTML
assert '<div id="precBlock" hidden>' in _MAP_HTML
# #intensityRow ouvre juste après la fermeture de #precBlock (frère, pas
# enfant) : rien de precBlock (modeSeg/compareCtl/precLegend) ne le suit.
after_prec_close = _MAP_HTML[_MAP_HTML.index('<div class="prec-legend" id="precLegend"'):]
close_idx = after_prec_close.index("</div>\n </div>")
between = after_prec_close[:close_idx]
assert 'id="intensityRow"' not in between
assert '<div class="row" id="intensityRow">' in after_prec_close[close_idx:close_idx + 200]
def test_ui_full_shared_link_without_intensity_resets_to_one():
"""M5 : un lien complet (couche ou mode dans le hash) sans &I signifie
1× (viewHash n'écrit &I que si ≠ 1), jamais l'intensité restée dans le
localStorage de ce navigateur."""
from lidar_pipeline.mapui import _MAP_JS
load = _MAP_JS[_MAP_JS.index("function loadState("):_MAP_JS.index("function saveState(")]
assert "else if (hash.main || hash.view) st.intensity = 1;" in load
def test_ui_compare_clip_skips_when_already_off():
"""M7 : applyCompareClip sort tôt quand le mode n'est pas Comparer et que
les calques sont déjà sans découpe — évite de relire/réécrire le style de
tous les calques à chaque déplacement de carte alors que Comparer n'a
jamais été ouvert."""
from lidar_pipeline.mapui import _MAP_JS
clip = _MAP_JS[_MAP_JS.index("function applyCompareClip("):_MAP_JS.index("(function compareDrag(")]
assert "let compareClipsCleared = true;" in _MAP_JS
assert "if (!on && compareClipsCleared) { el('compareBar').hidden = true; return; }" in clip
assert "compareClipsCleared = !on;" in clip

View File

@ -16,10 +16,10 @@
<div id="sheetGrip" class="sheet-grip" title="Glisser pour agrandir ou réduire"><span></span></div>
<div class="panel-head">
<nav id="panelTabs" class="panel-tabs" role="tablist">
<button role="tab" data-tab="view" title="Affichage (1)"><span class="tab-ico">◧</span><span class="tab-lbl">Affichage</span></button>
<button role="tab" data-tab="view" title="Affichage (1)"><span class="tab-ico">◧</span><span class="tab-lbl">Vue</span></button>
<button role="tab" data-tab="tile" title="Dalle (2)"><span class="tab-ico">▦</span><span class="tab-lbl" id="tileTabLbl">Dalle</span></button>
<button role="tab" data-tab="print" title="Export PDF (3)"><span class="tab-ico">⎙</span><span class="tab-lbl">Export PDF</span></button>
<button role="tab" data-tab="gen" title="Génération (4)" hidden><span class="tab-ico">+</span><span class="tab-lbl">Génération</span></button>
<button role="tab" data-tab="print" title="Export PDF (3)"><span class="tab-ico">⎙</span><span class="tab-lbl">PDF</span></button>
<button role="tab" data-tab="gen" title="Génération (4)" hidden><span class="tab-ico">+</span><span class="tab-lbl">Générer</span></button>
<button role="tab" data-tab="share" title="Partager (5)"><span class="tab-ico">⎘</span><span class="tab-lbl">Partager</span></button>
</nav>
<button id="panelCollapse" class="btn-icon" title="Replier le panneau">‹</button>
@ -45,12 +45,6 @@
<button type="button" data-mode="precision" title="Précision géométrique seule : densité de points sol">Précision</button>
<button type="button" data-mode="compare" title="Comparer deux couches de part et d'autre d'une barre glissante (touche P : mode suivant)">Comparer</button>
</div>
<div class="row" id="intensityRow">
<label for="reliefIntensity" class="hint">Intensité du relief</label>
<input type="range" id="reliefIntensity" min="0.5" max="2" step="0.1" value="1"
title="Contraste du relief (1 = rendu standard, le PDF garde le rendu standard)">
<span class="panel-stat" id="reliefIntensityVal">1×</span>
</div>
<div class="row" id="compareCtl" hidden>
<select id="compareLeft" class="field" title="Couche à gauche de la barre"></select>
<span class="hint">⇆</span>
@ -61,6 +55,12 @@
<div class="prec-ticks"><span>≤ 0,35</span><span>4</span><span>≥ 45 pts/m²</span></div>
</div>
</div>
<div class="row" id="intensityRow">
<label for="reliefIntensity" class="hint">Intensité du relief</label>
<input type="range" id="reliefIntensity" min="0.5" max="2" step="0.1" value="1"
title="Contraste du relief (1 = rendu standard, le PDF garde le rendu standard)">
<span class="panel-stat" id="reliefIntensityVal">1×</span>
</div>
<div class="legend" id="legendBlock" title="Relief orienté : la teinte donne l'orientation de la pente, la clarté le relief local">
<canvas id="roseCanvas" width="96" height="96"></canvas>
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>

View File

@ -267,6 +267,10 @@ function loadState(meta) {
if (hash.compare) st.compare = hash.compare;
if (hash.base) st.base = hash.base;
if (hash.intensity != null) st.intensity = hash.intensity;
// Lien complet (couche ou mode dans le hash) sans &I : l'export du lien
// n'écrit &I que si ≠ 1 (viewHash), donc son absence signifie 1×, jamais
// l'intensité de confort restée dans le localStorage de ce navigateur.
else if (hash.main || hash.view) st.intensity = 1;
}
return st;
}
@ -463,9 +467,15 @@ function compareBounds() {
// à chaque déplacement. Même couche des deux côtés : aucune découpe.
// STATE.compare.pos garde la valeur demandée (lien, glisser) ; la séparation
// affichée, barre et découpe ensemble, est bornée à compareBounds().
// Mode différent de « compare » et calques déjà sans découpe : rien à faire.
// Sans cette garde, chaque déplacement de carte (map.on('move', …)) relit et
// réécrit le style de tous les calques pour ne poser que des clip-path vides,
// à chaque frame, même quand Comparer n'a jamais été ouvert.
let compareClipsCleared = true;
function applyCompareClip() {
if (!STATE) return;
const on = effectiveMode() === 'compare';
if (!on && compareClipsCleared) { el('compareBar').hidden = true; return; }
el('compareBar').hidden = !on;
const size = map.getSize();
let x = size.x * (on ? STATE.compare.pos : 50) / 100;
@ -485,6 +495,7 @@ function applyCompareClip() {
}
c.style.clipPath = clip;
}
compareClipsCleared = !on;
if (on) el('compareBar').style.left = (bounds.left + x) + 'px';
}
map.on('move', applyCompareClip);
@ -729,15 +740,17 @@ function showTileInfo(data, latlng) {
let SELECTED_TILE = null; // données de la dalle sélectionnée (ou null)
// Libellé de l'onglet : « Dalle » sans sélection, « Dalle col · row » avec.
function tileTabLabel() {
return SELECTED_TILE ? 'Dalle ' + SELECTED_TILE.col + ' · ' + SELECTED_TILE.row : 'Dalle';
// Le libellé de l'onglet reste « Dalle » (5 onglets dans 340 px : un libellé
// variable déborderait) ; col · row va dans le title de l'onglet (survol) et
// dans #tileTitle, toujours lisible dans la fiche elle-même.
function tileTabTitle() {
return (SELECTED_TILE ? 'Dalle ' + SELECTED_TILE.col + ' · ' + SELECTED_TILE.row : 'Dalle') + ' (2)';
}
// Rafraîchit l'onglet Dalle (libellé + bascule hint/fiche) qu'il soit visible
// ou non : son contenu doit être à jour dès qu'on l'ouvre.
function renderTabTile() {
el('tileTabLbl').textContent = tileTabLabel();
document.querySelector('button[data-tab="tile"]').title = tileTabTitle();
el('tileHint').hidden = !!SELECTED_TILE;
el('tileCard').hidden = !SELECTED_TILE;
if (SELECTED_TILE) showTileInfo(SELECTED_TILE, lastTileQuery);
@ -1067,6 +1080,14 @@ function genAfterRun() {
renderUseCard();
}).catch(() => {});
fetch('api/map/background', { method: 'POST' }).catch(() => {});
// Fiche de dalle restée sélectionnée pendant le run : périmée (résolution,
// date de génération, couches…) — on la recharge sans jamais désélectionner.
if (SELECTED_TILE && lastTileQuery) {
fetch('api/map/tile?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => { if (d) selectTile(d, lastTileQuery); });
}
}
function genUpdateStatus(d) {
@ -1488,6 +1509,9 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
GEN.allowed = d.regen_allowed !== false;
const show = GEN.available && GEN.allowed;
panelRender();
// La fiche de dalle a pu s'afficher avant que /api/status ne réponde
// (bouton « Régénérer » absent, GEN encore inconnu) : la ré-émettre.
if (SELECTED_TILE) renderTabTile();
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => {
@ -1516,14 +1540,22 @@ window.addEventListener('hashchange', () => {
});
map.on('moveend zoomend', saveHashSoon);
// Jeton de séquence : une réponse arrivée après un clic plus récent (double
// clic, requête lente) ne doit jamais écraser l'état de la sélection.
let tileClickSeq = 0;
map.on('click', (e) => {
// Pas de sélection pendant un tracé de zone ni juste après le relâchement
// de la poignée du cadre d'export.
// de la poignée du cadre d'export. Un double-clic (zoom Leaflet) arrive ici
// comme deux clics simples : le second (detail > 1) ne doit pas rebasculer
// la sélection qui vient d'être posée par le premier.
if (genDrawing || Date.now() < genIgnoreClickUntil || PRINT.dragging) return;
if (e.originalEvent && e.originalEvent.detail > 1) return;
const seq = ++tileClickSeq;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => {
if (seq !== tileClickSeq) return; // clic plus récent déjà traité
if (!d) { toast('Serveur injoignable : réessayez dans un instant.', 5000); return; }
// Re-clic sur la dalle déjà sélectionnée : désélection. Sinon la
// sélection se déplace, sans changer l'onglet affiché.