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 ## 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. - **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`). - **`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. - **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). - **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 l'on change en glissant la poignée, en la touchant simplement (un cran), ou
en retouchant l'onglet déjà actif. en retouchant l'onglet déjà actif.
Un clic sur la carte ouvre une **bulle** sur la dalle LiDAR HD sous le Un clic sur la carte **sélectionne** la dalle LiDAR HD sous le curseur
curseur (date d'acquisition, densité de points sol) ; son bouton **Détails** (contour en pointillés) et remplit l'onglet **Dalle** (emprise, IGN, recalage
bascule une **fiche complète** dans le panneau (emprise, IGN, recalage des des passes) sans changer l'onglet affiché ; re-cliquer la même dalle
passes) — **← Retour** rouvre l'onglet précédent. Raccourcis clavier : désélectionne, cliquer une autre déplace la sélection. Raccourcis clavier :
**1–4** (onglets du panneau, sans effet si l'onglet est masqué), **P** **1–5** (onglets du panneau, sans effet si l'onglet est masqué), **P**
(mode d'affichage suivant), **Échap** (ferme la bulle, puis la fiche, puis (mode d'affichage suivant), **Échap** (désélectionne la dalle, puis
replie le panneau). Deux thèmes clair/sombre (bouton ◐/☀/☾, `auto` par 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 défaut = suit le système) ; réglages et état du panneau retenus dans
`localStorage` (`lidarMapView_v2`, `lidar-print`, `lidar-panel`, `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 ; s'additionnent ;
- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui - **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui
manquent au moins une des couches demandées ; manquent au moins une des couches demandées ;
- **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, bulle → - **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, onglet
Détails) — une dalle précise, même sans données existantes. Dalle) — une dalle précise, même sans données existantes.
Options du run : couches visées (défaut : les couches du panneau) et 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 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 ## Fiche de dalle et rose des vents
Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre 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, jaune en pointillés) et remplit l'onglet **Dalle** — nom, emprise Lambert 93,
densité de points sol — qu'elle soit générée ou non. Son bouton **Détails** puis, si la dalle est rendue, la résolution, la date de génération et le
bascule une fiche complète dans le panneau : nom, emprise Lambert 93, puis, recalage vertical des passes (faisceaux, décalages, correction des lignes) —
si la dalle est rendue, la résolution, la date de génération et le recalage qu'elle soit générée ou non, sans changer l'onglet affiché. Les informations
vertical des passes (faisceaux, décalages, correction des lignes). Les IGN arrivent à part (`GET /api/map/ign?col&row`, catalogue STAC mis en cache
informations IGN arrivent à part (`GET /api/map/ign?col&row`, catalogue STAC dans `output/ign_meta/`) : date et heure du scan LiDAR, capteurs, mission,
mis en cache dans `output/ign_meta/`) : date et heure du scan LiDAR, opérateur, date d'édition, procédé de classement, nombre de points et lien de
capteurs, mission, opérateur, date d'édition, procédé de classement, nombre téléchargement du nuage `.copc.laz` sur la géoplateforme. Un catalogue lent
de points et lien de téléchargement du nuage `.copc.laz` sur la ou injoignable n'empêche jamais la sélection. Re-cliquer la même dalle (ou
géoplateforme. Un catalogue lent ou injoignable n'empêche jamais la Échap) désélectionne.
sélection. **← Retour** (ou Échap) ferme la fiche et rouvre l'onglet
précédent.
Quand le relief orienté est affiché, une rose des vents donne la couleur de 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é 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 assert "pane: 'tileSel'" in _MAP_JS
def test_ui_tile_popup_and_details_view(): def test_ui_tile_tab_replaces_popup_and_details_view():
"""La fiche de dalle est une bulle Leaflet + une vue détaillée dans le panneau.""" """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 from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
assert 'id="tilecard"' not in _MAP_HTML 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_ 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 fn in _MAP_JS, fn
assert "L.popup(" in _MAP_JS and "Détails" in _MAP_JS # Pas de sélection pendant le déplacement du cadre d'export ni un tracé
# Pas de bulle pendant le déplacement du cadre d'export ni un tracé
click = _MAP_JS[_MAP_JS.index("map.on('click'"):] click = _MAP_JS[_MAP_JS.index("map.on('click'"):]
assert "genDrawing" in click[:400] and "PRINT.dragging" in click[:400] assert "genDrawing" in click[:400] and "PRINT.dragging" in click[:400]
# Changer d'onglet pendant que la fiche est ouverte nettoie le contour # Re-clic sur la dalle déjà sélectionnée : désélection plutôt que bascule
# de sélection (pas de fuite de tileSel) : openTab passe par le même assert "clearTileSelection();" in click[click.index("SELECTED_TILE"):click.index("});")]
# 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]
def test_defaults_roundtrip(tmp_path, monkeypatch): 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 assert "className: themeIsDark() ? 'base-dark' : ''" in _MAP_JS
def test_ui_phone_return_and_escape_keep_sheet_height(): def test_ui_escape_deselects_tile_before_collapsing_panel():
"""« ← Retour » et Échap depuis la fiche de dalle rétablissent l'onglet """Échap désélectionne la dalle (si sélection il y a) avant de replier le
précédent SANS repasser par openTab (qui retoucherait la hauteur du volet panneau ; un tracé de zone en cours est annulé en premier, comme avant."""
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."""
from lidar_pipeline.mapui import _MAP_JS from lidar_pipeline.mapui import _MAP_JS
assert "function restoreTabAfterDetails()" in _MAP_JS keydown = _MAP_JS[_MAP_JS.index("document.addEventListener('keydown'"):]
restore = _MAP_JS[_MAP_JS.index("function restoreTabAfterDetails("):] esc = keydown[keydown.rindex("if (e.key === 'Escape')"):]
assert "PANEL.tab = PANEL.want = tileReturnTab || 'view';" in restore[:300] assert "genSetDrawing(false)" in esc[:200]
assert "openTab(" not in restore[:300] assert "clearTileSelection()" in esc[:400]
close_tiles = _MAP_JS[_MAP_JS.index("function closeTileViews("):] assert "setSheet('closed')" in esc[:600] and "setCollapsed(true)" in esc[:600]
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]
def test_ui_saved_print_tab_does_not_rezoom_shared_link(): 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).""" après une ouverture explicite ultérieure de l'onglet)."""
from lidar_pipeline.mapui import _MAP_JS from lidar_pipeline.mapui import _MAP_JS
assert "let BOOTSTRAPPING = true;" in _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;"):] then_branch = _MAP_JS[_MAP_JS.index("panelRender();\n BOOTSTRAPPING = false;"):]
assert then_branch.startswith("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."):] catch_branch = _MAP_JS[_MAP_JS.index(".catch(() => {\n toast('Serveur de tuiles injoignable."):]
assert "BOOTSTRAPPING = false;" in catch_branch[:400] assert "BOOTSTRAPPING = false;" in catch_branch[:400]
def test_ui_details_view_hides_print_frame(): def test_ui_tile_selection_does_not_affect_print_frame():
"""Ouvrir la fiche détaillée pendant que l'onglet Export est affiché """La sélection d'une dalle vit à part des onglets : elle ne referme ni
referme le cadre d'export (sinon cadre + poignée restent visibles ne rouvre le cadre d'export (contrairement à l'ancienne fiche « Détails »,
derrière la fiche) — et le reste tant que la fiche est ouverte, quel que qui masquait les autres onglets par-dessus la carte)."""
soit ce qui redéclenche un rendu du panneau (grip, redimensionnement,
changement phoneQuery…) puisque panelVisibleTab() renvoie null tant que
#tileView n'est pas masqué."""
from lidar_pipeline.mapui import _MAP_JS from lidar_pipeline.mapui import _MAP_JS
open_details = _MAP_JS[_MAP_JS.index("function openTileDetails("):] select_fn = _MAP_JS[_MAP_JS.index("function selectTile("):]
assert "printSetOpen(false);" in open_details[:600] assert "printSetOpen" not in select_fn[:select_fn.index("function clearTileSelection(")]
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]
open_fn = _MAP_JS[_MAP_JS.index("function printSetOpen("):] open_fn = _MAP_JS[_MAP_JS.index("function printSetOpen("):]
assert "explicit && !map.getBounds().contains(PRINT.center)" in open_fn[:700] assert "explicit && !map.getBounds().contains(PRINT.center)" in open_fn[:700]
assert "printRefreshFrame(!!explicit);" 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(): def test_ui_single_tabbed_panel():
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
for id_ in ("panel", "panelTabs", "panelBody", "panelCollapse", "sheetGrip", 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_ 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 assert f'data-tab="{tab}"' in _MAP_HTML, tab
# Plus d'éléments flottants : tout passe dans le panneau # Plus d'éléments flottants : tout passe dans le panneau
for gone in ('id="layercard"', 'id="printcard"', 'id="gencard"', 'id="usecard"', 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("): "function setCollapsed(", "function panelVisibleTab("):
assert fn in _MAP_JS, fn assert fn in _MAP_JS, fn
assert "'panel:tab'" in _MAP_JS 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 "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 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"'): 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 { font-size: var(--fs-xs); color: var(--text); line-height: 1.3; }
.rose-cap span { color: var(--muted); font-size: var(--fs-xs); } .rose-cap span { color: var(--muted); font-size: var(--fs-xs); }
.tile-head .sec-title { margin: 0; flex: 1; } .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 --------------------------------------------------- */ /* --- composants communs --------------------------------------------------- */
.btn, button { display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-1); .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"> <div class="panel-head">
<nav id="panelTabs" class="panel-tabs" role="tablist"> <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">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="tile" title="Dalle (2)"><span class="tab-ico">▦</span><span class="tab-lbl" id="tileTabLbl">Dalle</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="print" title="Export PDF (3)"><span class="tab-ico">⎙</span><span class="tab-lbl">Export PDF</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="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> </nav>
<button id="panelCollapse" class="btn-icon" title="Replier le panneau">‹</button> <button id="panelCollapse" class="btn-icon" title="Replier le panneau">‹</button>
</div> </div>
@ -60,6 +61,13 @@
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div> <div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>
</div> </div>
</section> </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> <section id="tab-print" role="tabpanel" hidden>
<div class="panel-stat" id="printInfo"></div> <div class="panel-stat" id="printInfo"></div>
<div class="row"><select id="printPaper" class="field" title="Format papier"> <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> <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> <button id="btnReset" class="btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button></div>
</section> </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> </div>
</aside> </aside>

View File

@ -56,11 +56,6 @@ let STATE = null;
// de /api/map/meta (sinon un serveur injoignable au démarrage le laisse // de /api/map/meta (sinon un serveur injoignable au démarrage le laisse
// bloqué à vrai pour toujours : l'onglet Export ne recadrerait plus jamais). // bloqué à vrai pour toujours : l'onglet Export ne recadrerait plus jamais).
let BOOTSTRAPPING = true; 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 ------------------------------------------------------------------ // --- thème ------------------------------------------------------------------
// auto = suit le système ; ☀/☾ force clair ou sombre ; 3ᵉ appui : auto. // 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); }); .then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
} }
let tilePopup = null; let SELECTED_TILE = null; // données de la dalle sélectionnée (ou null)
let tileReturnTab = null; // onglet à rouvrir après « ← Retour »
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; lastTileQuery = latlng || lastTileQuery;
// Un nouveau clic pendant que la fiche détaillée d'une AUTRE dalle est SELECTED_TILE = data;
// 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;
selectTileOutline(data); selectTileOutline(data);
showTilePopup.data = data; renderTabTile();
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; }
});
} }
function openTileDetails() { // Désélectionne (re-clic sur la même dalle, Échap) : retire le contour et
const data = showTilePopup.data; // repasse l'onglet Dalle sur son indication.
if (!data) return; function clearTileSelection() {
if (tilePopup) { tilePopup.off('remove'); tilePopup.remove(); tilePopup = null; } SELECTED_TILE = 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;
if (tileSel) { map.removeLayer(tileSel); tileSel = 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é -------------------------------------- // --- rose des vents du relief orienté --------------------------------------
// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation // Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu), // (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) genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(d => { .then(d => {
genAddCells([{ col: d.col, row: d.row, corners: d.corners }]); genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
closeTileViews();
genOpenCard('1 dalle sélectionnée — options puis « Générer ».'); genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
}) })
.catch(msg => toast('Échec : ' + msg.message, 6000)); .catch(msg => toast('Échec : ' + msg.message, 6000));
@ -1324,7 +1270,7 @@ el('printTitle').addEventListener('change', printSave);
printLoad(); printLoad();
document.addEventListener('panel:tab', (e) => { 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); 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 // 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). // navigateur a le droit de lancer (même règle que les anciens boutons).
function panelTabs() { 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 // Onglet réellement visible, ou null (panneau replié ou volet fermé —
// détaillée affichée par-dessus — auquel cas AUCUN onglet n'est à l'écran, // auquel cas AUCUN onglet n'est à l'écran, pas même celui mémorisé :
// pas même celui mémorisé : panel:tab doit fermer le cadre d'export s'il // panel:tab doit fermer le cadre d'export s'il était ouvert, quel que soit
// était ouvert, quel que soit ce qui a déclenché ce rendu du panneau — grip // ce qui a déclenché ce rendu du panneau — grip glissée/touchée,
// glissée/touchée, redimensionnement, changement phoneQuery, le // redimensionnement, changement phoneQuery, le setTimeout(panelRender, 220)
// setTimeout(panelRender, 220) d'openTab…). // d'openTab…).
function panelVisibleTab() { function panelVisibleTab() {
if (!el('tileView').hidden) return null;
if (isPhone() ? PANEL.sheet === 'closed' : PANEL.collapsed) return null; if (isPhone() ? PANEL.sheet === 'closed' : PANEL.collapsed) return null;
return PANEL.tab; return PANEL.tab;
} }
@ -1377,9 +1322,7 @@ function panelRender() {
b.hidden = !tabs.includes(b.dataset.tab); b.hidden = !tabs.includes(b.dataset.tab);
b.setAttribute('aria-selected', String(b.dataset.tab === PANEL.tab)); b.setAttribute('aria-selected', String(b.dataset.tab === PANEL.tab));
}); });
if (el('tileView').hidden) { for (const t of ['view', 'tile', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
}
const panel = el('panel'); const panel = el('panel');
// Repli en bande d'icônes : ordinateur seulement (le téléphone a son volet). // Repli en bande d'icônes : ordinateur seulement (le téléphone a son volet).
const collapsed = PANEL.collapsed && !isPhone(); const collapsed = PANEL.collapsed && !isPhone();
@ -1396,7 +1339,6 @@ function panelRender() {
function openTab(tab) { function openTab(tab) {
if (!panelTabs().includes(tab)) return; if (!panelTabs().includes(tab)) return;
if (!el('tileView').hidden) hideTileView();
const before = PANEL.sheet; const before = PANEL.sheet;
// Onglet actif retouché sur téléphone : le volet change de hauteur. // Onglet actif retouché sur téléphone : le volet change de hauteur.
if (isPhone() && tab === PANEL.tab && PANEL.sheet !== 'closed') { if (isPhone() && tab === PANEL.tab && PANEL.sheet !== 'closed') {
@ -1523,13 +1465,22 @@ window.addEventListener('hashchange', () => {
map.on('moveend zoomend', saveHashSoon); map.on('moveend zoomend', saveHashSoon);
map.on('click', (e) => { map.on('click', (e) => {
// Pas de bulle pendant un tracé de zone ni juste après le relâchement de // Pas de sélection pendant un tracé de zone ni juste après le relâchement
// la poignée du cadre d'export. // de la poignée du cadre d'export.
if (genDrawing || Date.now() < genIgnoreClickUntil || PRINT.dragging) return; if (genDrawing || Date.now() < genIgnoreClickUntil || PRINT.dragging) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng) fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.catch(() => 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', () => { el('btnShare').addEventListener('click', () => {
@ -1644,15 +1595,15 @@ document.addEventListener('keydown', (e) => {
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true); setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return; return;
} }
// 1–4 : onglets du panneau (Génération ignorée si masquée) // 1–5 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-4]$/.test(e.key)) { if (/^[1-5]$/.test(e.key)) {
const tab = ['view', 'print', 'gen', 'share'][Number(e.key) - 1]; const tab = ['view', 'tile', 'print', 'gen', 'share'][Number(e.key) - 1];
if (panelTabs().includes(tab)) openTab(tab); if (panelTabs().includes(tab)) openTab(tab);
return; return;
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (genDrawing) { genSetDrawing(false); return; } 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); if (isPhone()) setSheet('closed'); else setCollapsed(true);
} }
}); });