Remplacer la bulle et la fiche détaillée de dalle par un onglet Dalle du panneau

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 20:22:07 +02:00
parent 0df93c2f00
commit 90663405f7
6 changed files with 112 additions and 188 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 / 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 ouvre une bulle (date, densité) → « Détails » bascule une fiche complète dans le panneau (« ← Retour » rouvre l'onglet précédent) ; Échap ferme la bulle, puis la fiche, 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–4 (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. 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

@ -29,12 +29,12 @@ clic sur un onglet redéplie le panneau). Sous 720 px de large, il devient un
l'on change en glissant la poignée, en la touchant simplement (un cran), ou
en retouchant l'onglet déjà actif.
Un clic sur la carte ouvre une **bulle** sur la dalle LiDAR HD sous le
curseur (date d'acquisition, densité de points sol) ; son bouton **Détails**
bascule une **fiche complète** dans le panneau (emprise, IGN, recalage des
passes) — **← Retour** rouvre l'onglet précédent. Raccourcis clavier :
**1–4** (onglets du panneau, sans effet si l'onglet est masqué), **P**
(mode d'affichage suivant), **Échap** (ferme la bulle, puis la fiche, puis
Un clic sur la carte **sélectionne** la dalle LiDAR HD sous le curseur
(contour en pointillés) 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. Raccourcis clavier :
**1–5** (onglets du panneau, sans effet si l'onglet est masqué), **P**
(mode d'affichage suivant), **Échap** (désélectionne la dalle, puis
replie le panneau). Deux thèmes clair/sombre (bouton ◐/☀/☾, `auto` par
défaut = suit le système) ; réglages et état du panneau retenus dans
`localStorage` (`lidarMapView_v2`, `lidar-print`, `lidar-panel`,
@ -48,8 +48,8 @@ défaut = suit le système) ; réglages et état du panneau retenus dans
s'additionnent ;
- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui
manquent au moins une des couches demandées ;
- **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, bulle →
Détails) — une dalle précise, même sans données existantes.
- **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, onglet
Dalle) — une dalle précise, même sans données existantes.
Options du run : couches visées (défaut : les couches du panneau) et
régénération forcée. La classification du sol (IGN, sol seul) et le raccord
@ -130,18 +130,16 @@ source manquée pendant qu'il était éteint est reprise au scan suivant.
## Fiche de dalle et rose des vents
Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre
jaune en pointillés) et ouvre une **bulle** — nom, date d'acquisition,
densité de points sol — qu'elle soit générée ou non. Son bouton **Détails**
bascule une fiche complète dans le panneau : nom, emprise Lambert 93, puis,
si la dalle est rendue, la résolution, la date de génération et le recalage
vertical des passes (faisceaux, décalages, correction des lignes). Les
informations IGN arrivent à part (`GET /api/map/ign?col&row`, catalogue STAC
mis en cache dans `output/ign_meta/`) : date et heure du scan LiDAR,
capteurs, mission, opérateur, date d'édition, procédé de classement, nombre
de points et lien de téléchargement du nuage `.copc.laz` sur la
géoplateforme. Un catalogue lent ou injoignable n'empêche jamais la
sélection. **← Retour** (ou Échap) ferme la fiche et rouvre l'onglet
précédent.
jaune en pointillés) et remplit l'onglet **Dalle** — nom, emprise Lambert 93,
puis, si la dalle est rendue, la résolution, la date de génération et le
recalage vertical des passes (faisceaux, décalages, correction des lignes) —
qu'elle soit générée ou non, sans changer l'onglet affiché. Les informations
IGN arrivent à part (`GET /api/map/ign?col&row`, catalogue STAC mis en cache
dans `output/ign_meta/`) : date et heure du scan LiDAR, capteurs, mission,
opérateur, date d'édition, procédé de classement, nombre de points et lien de
téléchargement du nuage `.copc.laz` sur la géoplateforme. Un catalogue lent
ou injoignable n'empêche jamais la sélection. Re-cliquer la même dalle (ou
Échap) désélectionne.
Quand le relief orienté est affiché, une rose des vents donne la couleur de
chaque orientation de pente (même formule CIELAB que le rendu) ; la clarté

View File

@ -346,24 +346,26 @@ def test_map_ui_tile_load_indicator_and_selection_pane():
assert "pane: 'tileSel'" in _MAP_JS
def test_ui_tile_popup_and_details_view():
"""La fiche de dalle est une bulle Leaflet + une vue détaillée dans le panneau."""
def test_ui_tile_tab_replaces_popup_and_details_view():
"""Un clic sur la carte sélectionne la dalle (contour) et remplit
l'onglet Dalle : plus de bulle Leaflet ni de vue « Détails » séparée."""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
assert 'id="tilecard"' not in _MAP_HTML
for id_ in ("tileView", "tileBack", "tileTitle", "tileBody"):
assert 'data-tab="tile"' in _MAP_HTML
for id_ in ("tab-tile", "tileHint", "tileCard", "tileTitle", "tileBody", "tileTabLbl"):
assert f'id="{id_}"' in _MAP_HTML, id_
for fn in ("function showTilePopup(", "function openTileDetails(", "function closeTileViews("):
for gone in ("tileView", "tileBack", "showTilePopup", "openTileDetails",
"closeTileViews", "hideTileView", "restoreTabAfterDetails",
"RESTORING_TAB", "tileReturnTab", "L.popup("):
assert gone not in _MAP_JS, gone
assert "leaflet-popup" not in _MAP_CSS and "tile-pop" not in _MAP_CSS
for fn in ("function selectTile(", "function clearTileSelection(", "function renderTabTile("):
assert fn in _MAP_JS, fn
assert "L.popup(" in _MAP_JS and "Détails" in _MAP_JS
# Pas de bulle pendant le déplacement du cadre d'export ni un tracé
# Pas de sélection pendant le déplacement du cadre d'export ni un tracé
click = _MAP_JS[_MAP_JS.index("map.on('click'"):]
assert "genDrawing" in click[:400] and "PRINT.dragging" in click[:400]
# Changer d'onglet pendant que la fiche est ouverte nettoie le contour
# de sélection (pas de fuite de tileSel) : openTab passe par le même
# nettoyage que closeTileViews, sans se rappeler lui-même.
assert "function hideTileView(" in _MAP_JS
open_tab = _MAP_JS[_MAP_JS.index("function openTab("):]
assert "hideTileView()" in open_tab[:200]
# Re-clic sur la dalle déjà sélectionnée : désélection plutôt que bascule
assert "clearTileSelection();" in click[click.index("SELECTED_TILE"):click.index("});")]
def test_defaults_roundtrip(tmp_path, monkeypatch):
@ -567,22 +569,15 @@ def test_ui_base_dark_follows_theme_unless_chosen():
assert "className: themeIsDark() ? 'base-dark' : ''" in _MAP_JS
def test_ui_phone_return_and_escape_keep_sheet_height():
"""« ← Retour » et Échap depuis la fiche de dalle rétablissent l'onglet
précédent SANS repasser par openTab (qui retoucherait la hauteur du volet
en re-touchant l'onglet déjà actif) : la fiche ferme via un rétablissement
direct de PANEL.tab/want, partagé par closeTileViews et showTilePopup."""
def test_ui_escape_deselects_tile_before_collapsing_panel():
"""Échap désélectionne la dalle (si sélection il y a) avant de replier le
panneau ; un tracé de zone en cours est annulé en premier, comme avant."""
from lidar_pipeline.mapui import _MAP_JS
assert "function restoreTabAfterDetails()" in _MAP_JS
restore = _MAP_JS[_MAP_JS.index("function restoreTabAfterDetails("):]
assert "PANEL.tab = PANEL.want = tileReturnTab || 'view';" in restore[:300]
assert "openTab(" not in restore[:300]
close_tiles = _MAP_JS[_MAP_JS.index("function closeTileViews("):]
assert "restoreTabAfterDetails()" in close_tiles[:250]
# Second clic sur la carte pendant que la fiche est ouverte : referme la
# fiche plutôt que de bouger le contour derrière elle (M5)
popup_fn = _MAP_JS[_MAP_JS.index("function showTilePopup("):]
assert "restoreTabAfterDetails()" in popup_fn[:400]
keydown = _MAP_JS[_MAP_JS.index("document.addEventListener('keydown'"):]
esc = keydown[keydown.rindex("if (e.key === 'Escape')"):]
assert "genSetDrawing(false)" in esc[:200]
assert "clearTileSelection()" in esc[:400]
assert "setSheet('closed')" in esc[:600] and "setCollapsed(true)" in esc[:600]
def test_ui_saved_print_tab_does_not_rezoom_shared_link():
@ -594,36 +589,20 @@ def test_ui_saved_print_tab_does_not_rezoom_shared_link():
après une ouverture explicite ultérieure de l'onglet)."""
from lidar_pipeline.mapui import _MAP_JS
assert "let BOOTSTRAPPING = true;" in _MAP_JS
assert "printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING && !RESTORING_TAB);" in _MAP_JS
assert "printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING);" in _MAP_JS
then_branch = _MAP_JS[_MAP_JS.index("panelRender();\n BOOTSTRAPPING = false;"):]
assert then_branch.startswith("panelRender();\n BOOTSTRAPPING = false;")
catch_branch = _MAP_JS[_MAP_JS.index(".catch(() => {\n toast('Serveur de tuiles injoignable."):]
assert "BOOTSTRAPPING = false;" in catch_branch[:400]
def test_ui_details_view_hides_print_frame():
"""Ouvrir la fiche détaillée pendant que l'onglet Export est affiché
referme le cadre d'export (sinon cadre + poignée restent visibles
derrière la fiche) — et le reste tant que la fiche est ouverte, quel que
soit ce qui redéclenche un rendu du panneau (grip, redimensionnement,
changement phoneQuery…) puisque panelVisibleTab() renvoie null tant que
#tileView n'est pas masqué."""
def test_ui_tile_selection_does_not_affect_print_frame():
"""La sélection d'une dalle vit à part des onglets : elle ne referme ni
ne rouvre le cadre d'export (contrairement à l'ancienne fiche « Détails »,
qui masquait les autres onglets par-dessus la carte)."""
from lidar_pipeline.mapui import _MAP_JS
open_details = _MAP_JS[_MAP_JS.index("function openTileDetails("):]
assert "printSetOpen(false);" in open_details[:600]
visible_tab = _MAP_JS[_MAP_JS.index("function panelVisibleTab("):]
assert "if (!el('tileView').hidden) return null;" in visible_tab[:300]
def test_ui_restoring_tab_does_not_refit_or_recentre_print_frame():
"""Rétablir l'onglet Export (retour de la fiche détaillée) rouvre le
cadre là où il était, sans recadrer la vue ni le recentrer sur l'écran —
au contraire d'une ouverture explicite de l'onglet par l'utilisateur."""
from lidar_pipeline.mapui import _MAP_JS
assert "let RESTORING_TAB = false;" in _MAP_JS
restore = _MAP_JS[_MAP_JS.index("function restoreTabAfterDetails("):]
assert "RESTORING_TAB = true;" in restore[:400]
assert "panelRender();\n RESTORING_TAB = false;" in restore[:400]
select_fn = _MAP_JS[_MAP_JS.index("function selectTile("):]
assert "printSetOpen" not in select_fn[:select_fn.index("function clearTileSelection(")]
open_fn = _MAP_JS[_MAP_JS.index("function printSetOpen("):]
assert "explicit && !map.getBounds().contains(PRINT.center)" in open_fn[:700]
assert "printRefreshFrame(!!explicit);" in open_fn[:700]
@ -1658,9 +1637,9 @@ def test_ui_tokens_themes_and_components():
def test_ui_single_tabbed_panel():
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
for id_ in ("panel", "panelTabs", "panelBody", "panelCollapse", "sheetGrip",
"tab-view", "tab-print", "tab-gen", "tab-share"):
"tab-view", "tab-tile", "tab-print", "tab-gen", "tab-share"):
assert f'id="{id_}"' in _MAP_HTML, id_
for tab in ("view", "print", "gen", "share"):
for tab in ("view", "tile", "print", "gen", "share"):
assert f'data-tab="{tab}"' in _MAP_HTML, tab
# Plus d'éléments flottants : tout passe dans le panneau
for gone in ('id="layercard"', 'id="printcard"', 'id="gencard"', 'id="usecard"',
@ -1675,7 +1654,7 @@ def test_ui_single_tabbed_panel():
"function setCollapsed(", "function panelVisibleTab("):
assert fn in _MAP_JS, fn
assert "'panel:tab'" in _MAP_JS
# Génération masquée : onglet retiré de la liste (touches 1–4 comprises)
# Génération masquée : onglet retiré de la liste (touches 1–5 comprises)
assert "GEN.available && GEN.allowed" in _MAP_JS.split("function panelTabs(")[1].split("}")[0]
assert "@media (max-width: 720px)" in _MAP_CSS and "#panel" in _MAP_CSS
for state in ('data-sheet="closed"', 'data-sheet="half"', 'data-sheet="full"'):

View File

@ -85,13 +85,6 @@ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(-
.rose-cap { font-size: var(--fs-xs); color: var(--text); line-height: 1.3; }
.rose-cap span { color: var(--muted); font-size: var(--fs-xs); }
.tile-head .sec-title { margin: 0; flex: 1; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--surface); color: var(--text);
box-shadow: var(--shadow-lg); }
.leaflet-popup-content-wrapper { border-radius: var(--r-md); border: 1px solid var(--border); }
.leaflet-popup-content { margin: var(--sp-2) var(--sp-3); font-size: var(--fs-sm); line-height: 1.45; }
.leaflet-container a.leaflet-popup-close-button { color: var(--muted); }
.tile-pop b { font-size: var(--fs-md); }
.tile-pop .row { margin-top: var(--sp-2); }
/* --- composants communs --------------------------------------------------- */
.btn, button { display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-1);

View File

@ -17,9 +17,10 @@
<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="print" title="Export PDF (2)"><span class="tab-ico">⎙</span><span class="tab-lbl">Export PDF</span></button>
<button role="tab" data-tab="gen" title="Génération (3)" hidden><span class="tab-ico">+</span><span class="tab-lbl">Génération</span></button>
<button role="tab" data-tab="share" title="Partager (4)"><span class="tab-ico">⎘</span><span class="tab-lbl">Partager</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="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>
</div>
@ -60,6 +61,13 @@
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>
</div>
</section>
<section id="tab-tile" role="tabpanel" hidden>
<p class="hint" id="tileHint">Cliquez une dalle sur la carte pour la sélectionner.</p>
<div id="tileCard" hidden>
<div class="row tile-head"><span class="sec-title" id="tileTitle">Dalle</span></div>
<div id="tileBody"></div>
</div>
</section>
<section id="tab-print" role="tabpanel" hidden>
<div class="panel-stat" id="printInfo"></div>
<div class="row"><select id="printPaper" class="field" title="Format papier">
@ -110,11 +118,6 @@
<div class="row"><button id="btnDefault" class="btn" title="Figer l'affichage courant (couche principale, mode, fond) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
<button id="btnReset" class="btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button></div>
</section>
<section id="tileView" hidden>
<div class="row tile-head"><button id="tileBack" class="btn btn-ghost">← Retour</button>
<span class="sec-title" id="tileTitle">Dalle</span></div>
<div id="tileBody"></div>
</section>
</div>
</aside>

View File

@ -56,11 +56,6 @@ let STATE = null;
// de /api/map/meta (sinon un serveur injoignable au démarrage le laisse
// bloqué à vrai pour toujours : l'onglet Export ne recadrerait plus jamais).
let BOOTSTRAPPING = true;
// Vrai le temps d'un panelRender() qui RÉTABLIT un onglet déjà connu
// (restoreTabAfterDetails, retour de la fiche détaillée) plutôt que d'en
// ouvrir un nouveau à la demande de l'utilisateur : le cadre d'export ne doit
// alors ni recadrer la vue, ni recentrer le cadre lui-même (cf. printSetOpen).
let RESTORING_TAB = false;
// --- thème ------------------------------------------------------------------
// auto = suit le système ; ☀/☾ force clair ou sombre ; 3ᵉ appui : auto.
@ -688,87 +683,39 @@ function showTileInfo(data, latlng) {
.then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
}
let tilePopup = null;
let tileReturnTab = null; // onglet à rouvrir après « ← Retour »
let SELECTED_TILE = null; // données de la dalle sélectionnée (ou null)
function showTilePopup(data, latlng) {
// 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';
}
// 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();
el('tileHint').hidden = !!SELECTED_TILE;
el('tileCard').hidden = !SELECTED_TILE;
if (SELECTED_TILE) showTileInfo(SELECTED_TILE, lastTileQuery);
}
// Sélectionne une dalle (contour + fiche dans l'onglet Dalle) sans changer
// l'onglet affiché.
function selectTile(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
// Un nouveau clic pendant que la fiche détaillée d'une AUTRE dalle est
// ouverte : on la referme (sinon le contour bouge sur la carte alors que
// la fiche affiche encore l'ancienne dalle).
if (!el('tileView').hidden) restoreTabAfterDetails();
if (tilePopup) tilePopup.remove();
const html = !data
? '<div class="tile-pop">Serveur injoignable : réessayez dans un instant.</div>'
: '<div class="tile-pop"><b>Dalle ' + data.col + ' · ' + data.row + '</b><br>'
+ (data.found ? '' : 'Pas encore de rendu<br>')
+ 'Acquisition : ' + fmtAcq(data.quality) + '<br>'
+ 'Densité : ' + fmtDensity(data.quality)
+ '<div class="row"><button class="btn" id="tilePopDetails">Détails</button></div></div>';
tilePopup = L.popup({ maxWidth: 260, autoPanPaddingTopLeft: [isPhone() ? 12 : el('panel').getBoundingClientRect().right + 12, 12] })
.setLatLng(latlng).setContent(html).openOn(map);
if (!data) return;
SELECTED_TILE = data;
selectTileOutline(data);
showTilePopup.data = data;
const btn = el('tilePopDetails');
if (btn) btn.addEventListener('click', openTileDetails);
tilePopup.on('remove', () => {
tilePopup = null;
if (el('tileView').hidden && tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
renderTabTile();
}
function openTileDetails() {
const data = showTilePopup.data;
if (!data) return;
if (tilePopup) { tilePopup.off('remove'); tilePopup.remove(); tilePopup = null; }
tileReturnTab = PANEL.tab;
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = true;
el('tileView').hidden = false;
// La fiche masque l'onglet Export : son cadre et sa poignée n'ont rien à
// faire sur la carte tant qu'elle est ouverte (restoreTabAfterDetails les
// rétablit via panel:tab en revenant sur l'onglet Export).
printSetOpen(false);
if (isPhone() && PANEL.sheet === 'closed') setSheet('half');
if (!isPhone() && PANEL.collapsed) setCollapsed(false);
showTileInfo(data, lastTileQuery);
}
// Masque #tileView et retire le contour de sélection, sans toucher aux
// onglets (utilisé par closeTileViews et par openTab, qui ne doit pas
// se rappeler lui-même via closeTileViews).
function hideTileView() {
el('tileView').hidden = true;
// Désélectionne (re-clic sur la même dalle, Échap) : retire le contour et
// repasse l'onglet Dalle sur son indication.
function clearTileSelection() {
SELECTED_TILE = null;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
renderTabTile();
}
// Referme la fiche détaillée et rétablit l'onglet d'origine SANS passer par
// openTab : celui-ci retouche la hauteur du volet quand l'onglet visé est déjà
// l'onglet actif (règle « re-toucher l'onglet actif ») — ce qui n'a pas lieu
// d'être ici, on rouvre simplement ce qui était affiché avant.
function restoreTabAfterDetails() {
hideTileView();
PANEL.tab = PANEL.want = tileReturnTab || 'view';
panelSave();
// Ce rendu RÉTABLIT l'onglet Export tel qu'il était (cadre à sa place,
// vue inchangée) : ni recadrage de la vue, ni recentrage du cadre — cf.
// printSetOpen, qui lit ce drapeau via le listener panel:tab.
RESTORING_TAB = true;
panelRender();
RESTORING_TAB = false;
}
// Ferme la bulle, sinon la fiche ; renvoie true si quelque chose a été fermé.
function closeTileViews() {
if (tilePopup) { tilePopup.remove(); tilePopup = null; return true; }
if (!el('tileView').hidden) {
restoreTabAfterDetails();
return true;
}
return false;
}
el('tileBack').addEventListener('click', closeTileViews);
// --- rose des vents du relief orienté --------------------------------------
// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
@ -961,7 +908,6 @@ function genAddCellFromQuery() {
genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(d => {
genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
closeTileViews();
genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
})
.catch(msg => toast('Échec : ' + msg.message, 6000));
@ -1324,7 +1270,7 @@ el('printTitle').addEventListener('change', printSave);
printLoad();
document.addEventListener('panel:tab', (e) => {
printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING && !RESTORING_TAB);
printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING);
if (e.detail.visible !== 'gen' && genDrawing) genSetDrawing(false);
});
@ -1353,17 +1299,16 @@ const isPhone = () => phoneQuery.matches;
// Onglets proposés : Génération seulement si le générateur répond et que ce
// navigateur a le droit de lancer (même règle que les anciens boutons).
function panelTabs() {
return ['view', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
return ['view', 'tile', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
}
// Onglet réellement visible, ou null (panneau replié, volet fermé, ou fiche
// détaillée affichée par-dessus — auquel cas AUCUN onglet n'est à l'écran,
// pas même celui mémorisé : panel:tab doit fermer le cadre d'export s'il
// était ouvert, quel que soit ce qui a déclenché ce rendu du panneau — grip
// glissée/touchée, redimensionnement, changement phoneQuery, le
// setTimeout(panelRender, 220) d'openTab…).
// Onglet réellement visible, ou null (panneau replié ou volet fermé —
// auquel cas AUCUN onglet n'est à l'écran, pas même celui mémorisé :
// panel:tab doit fermer le cadre d'export s'il était ouvert, quel que soit
// ce qui a déclenché ce rendu du panneau — grip glissée/touchée,
// redimensionnement, changement phoneQuery, le setTimeout(panelRender, 220)
// d'openTab…).
function panelVisibleTab() {
if (!el('tileView').hidden) return null;
if (isPhone() ? PANEL.sheet === 'closed' : PANEL.collapsed) return null;
return PANEL.tab;
}
@ -1377,9 +1322,7 @@ function panelRender() {
b.hidden = !tabs.includes(b.dataset.tab);
b.setAttribute('aria-selected', String(b.dataset.tab === PANEL.tab));
});
if (el('tileView').hidden) {
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
}
for (const t of ['view', 'tile', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
const panel = el('panel');
// Repli en bande d'icônes : ordinateur seulement (le téléphone a son volet).
const collapsed = PANEL.collapsed && !isPhone();
@ -1396,7 +1339,6 @@ function panelRender() {
function openTab(tab) {
if (!panelTabs().includes(tab)) return;
if (!el('tileView').hidden) hideTileView();
const before = PANEL.sheet;
// Onglet actif retouché sur téléphone : le volet change de hauteur.
if (isPhone() && tab === PANEL.tab && PANEL.sheet !== 'closed') {
@ -1523,13 +1465,22 @@ window.addEventListener('hashchange', () => {
map.on('moveend zoomend', saveHashSoon);
map.on('click', (e) => {
// Pas de bulle pendant un tracé de zone ni juste après le relâchement de
// la poignée du cadre d'export.
// Pas de sélection pendant un tracé de zone ni juste après le relâchement
// de la poignée du cadre d'export.
if (genDrawing || Date.now() < genIgnoreClickUntil || PRINT.dragging) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => showTilePopup(d, e.latlng));
.then(d => {
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é.
if (SELECTED_TILE && SELECTED_TILE.col === d.col && SELECTED_TILE.row === d.row) {
clearTileSelection();
} else {
selectTile(d, e.latlng);
}
});
});
el('btnShare').addEventListener('click', () => {
@ -1644,15 +1595,15 @@ document.addEventListener('keydown', (e) => {
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return;
}
// 1–4 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-4]$/.test(e.key)) {
const tab = ['view', 'print', 'gen', 'share'][Number(e.key) - 1];
// 1–5 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-5]$/.test(e.key)) {
const tab = ['view', 'tile', 'print', 'gen', 'share'][Number(e.key) - 1];
if (panelTabs().includes(tab)) openTab(tab);
return;
}
if (e.key === 'Escape') {
if (genDrawing) { genSetDrawing(false); return; }
if (closeTileViews()) return; // bulle / fiche de dalle (Task 4)
if (SELECTED_TILE) { clearTileSelection(); return; }
if (isPhone()) setSheet('closed'); else setCollapsed(true);
}
});