Corriger la revue stage1 de la refonte : fiche de dalle, thème du fond, cadre d'export, tokens de couleur

Retour/Échap depuis la fiche détaillée ne retouche plus la hauteur du volet
téléphone (openTab remplacé par un rétablissement direct de l'onglet) ; le
fond OSM suit le thème sauf choix explicite (migration du localStorage et des
défauts serveur en v3, plus de dark:true hérité de l'ex-webapp) et part du
bon thème dès sa création (plus de flash sombre) ; l'onglet Export mémorisé ne
recadre plus la vue au premier rendu (lien partagé préservé) et referme son
cadre pendant que la fiche détaillée est ouverte ; la qualité affichée suit le
nom réel de la dalle rendue ; un second clic referme la fiche au lieu de
déplacer son contour derrière elle ; couleurs en dur restantes tokenisées
(--tile-sel, --gps, .gen-log) et lisibles en thème clair ; localStorage
corrompu (lidar-panel, lidar-print) n'y casse plus l'interface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 18:52:30 +02:00
parent 56c9fa0ccd
commit 72b10cd228
5 changed files with 249 additions and 25 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), `lidar-theme`. - **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`.
- **`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

@ -911,11 +911,15 @@ def _sanitize_defaults(req):
base_in = req.base if isinstance(req.base, dict) else {} base_in = req.base if isinstance(req.base, dict) else {}
base = {"on": bool(base_in.get("on", True)), base = {"on": bool(base_in.get("on", True)),
"opacity": _clamp01(base_in.get("opacity"), 0.85), "opacity": _clamp01(base_in.get("opacity"), 0.85),
"dark": bool(base_in.get("dark", True))} # None = pas de choix explicite (le fond suit le thème côté carte) ;
# seule une valeur booléenne reçue devient un choix figé.
"dark": None if base_in.get("dark") is None else bool(base_in.get("dark"))}
# v: 3 distingue ce format (dark éventuellement None = pas choisi) de
# l'ancien, qui écrivait toujours dark:true (voir map_meta).
return {"main": req.main if req.main in known else None, return {"main": req.main if req.main in known else None,
"mode": req.mode if req.mode in VIEW_MODES else "relief", "mode": req.mode if req.mode in VIEW_MODES else "relief",
"precision_opacity": _clamp01(req.precision_opacity, DEFAULT_PRECISION_OPACITY), "precision_opacity": _clamp01(req.precision_opacity, DEFAULT_PRECISION_OPACITY),
"base": base, "saved_at": time.time()} "base": base, "v": 3, "saved_at": time.time()}
class DefaultsRequest(BaseModel): class DefaultsRequest(BaseModel):
@ -974,6 +978,11 @@ def map_meta():
if main not in keys or main == PRECISION_VIZ: if main not in keys or main == PRECISION_VIZ:
main = default_main_layer(keys) main = default_main_layer(keys)
mode = saved.get("mode") if saved.get("mode") in VIEW_MODES else DEFAULT_VIEW_MODE mode = saved.get("mode") if saved.get("mode") in VIEW_MODES else DEFAULT_VIEW_MODE
# Fichier d'avant v3 (ex-webapp) : base.dark:true systématique, jamais un
# vrai choix — servi comme "non choisi" (None, le fond suit le thème).
base_default = saved.get("base")
if isinstance(base_default, dict) and saved.get("v") != 3:
base_default = dict(base_default, dark=None)
return { return {
"layers": infos, "layers": infos,
"default_main": main, "default_main": main,
@ -981,7 +990,7 @@ def map_meta():
"default_mode": mode, "default_mode": mode,
"default_precision_opacity": _clamp01(saved.get("precision_opacity"), "default_precision_opacity": _clamp01(saved.get("precision_opacity"),
DEFAULT_PRECISION_OPACITY), DEFAULT_PRECISION_OPACITY),
"default_base": saved.get("base"), "default_base": base_default,
"defaults_saved": bool(saved), "defaults_saved": bool(saved),
# L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en # L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en
# 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique. # 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique.
@ -1085,16 +1094,20 @@ def map_tile_info(lat: float, lng: float):
# coins NO, NE, SE, SO (lat, lng) : cadre de sélection sur la carte # coins NO, NE, SE, SO (lat, lng) : cadre de sélection sur la carte
"corners": [[round(la, 7), round(lo, 7)] for lo, la in zip(lons, lats)], "corners": [[round(la, 7), round(lo, 7)] for lo, la in zip(lons, lats)],
} }
q = read_quality(OUTPUT_DIR, base["name"])
base["quality"] = ({"ground_density": q.get("ground_density"),
"acq_start": q.get("acq_start"), "acq_end": q.get("acq_end")}
if q else None)
best = None best = None
for t in scan_tiles(OUTPUT_DIR / "visualisations"): for t in scan_tiles(OUTPUT_DIR / "visualisations"):
if (t["col"], t["row"]) != (col, row): if (t["col"], t["row"]) != (col, row):
continue continue
if best is None or t["resolution"] < best["resolution"]: if best is None or t["resolution"] < best["resolution"]:
best = t best = t
# Qualité (densité, dates) : sous le nom réel de la dalle rendue quand il y
# en a une (best["basename"] peut différer du nom nominal calculé depuis
# col/row) ; nom nominal sinon, faute de mieux.
quality_name = best["basename"] if best else base["name"]
q = read_quality(OUTPUT_DIR, quality_name)
base["quality"] = ({"ground_density": q.get("ground_density"),
"acq_start": q.get("acq_start"), "acq_end": q.get("acq_end")}
if q else None)
if best is None: if best is None:
return {"found": False, **base} return {"found": False, **base}
meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM") meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM")

View File

@ -268,6 +268,30 @@ def test_map_tile_quality_absent_is_none(tmp_path, monkeypatch):
assert mapserve.map_tile_info(lat=lat, lng=lon)["quality"] is None assert mapserve.map_tile_info(lat=lat, lng=lon)["quality"] is None
def test_map_tile_quality_uses_real_basename(tmp_path, monkeypatch):
"""La qualité lue est celle du nom réel de la dalle rendue (best["basename"]),
pas celle du nom nominal calculé depuis col/row — ils peuvent différer."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import index as index_mod
from lidar_pipeline.quality import write_quality, QUALITY_VERSION
_setup(tmp_path, monkeypatch, layers=("relief_oriente",))
nominal = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
real = nominal + "_r0p2"
write_quality(tmp_path, real, {"version": QUALITY_VERSION, "ground_density": 42.0,
"density_grid": [[42.0]], "empty_fraction": 0.0,
"acq_start": "2023-01-01", "acq_end": "2023-01-01",
"acq_source": "gps"})
fake_tile = {"col": 1054, "row": 6882, "resolution": 0.2, "basename": real, "viz": {}}
monkeypatch.setattr(index_mod, "scan_tiles", lambda d: [fake_tile])
monkeypatch.setattr(index_mod, "_collect_tile_metadata",
lambda t, dtm_dir: {"method": None, "generated": None, "viz": {}})
lat, lon = _center()
d = mapserve.map_tile_info(lat=lat, lng=lon)
assert d["found"] is True
assert d["name"] == real
assert d["quality"]["ground_density"] == 42.0
def test_healthz_and_assets(tmp_path, monkeypatch): def test_healthz_and_assets(tmp_path, monkeypatch):
"""Sonde de santé et assets de l'interface (revalidés, jamais immuables).""" """Sonde de santé et assets de l'interface (revalidés, jamais immuables)."""
from fastapi import HTTPException from fastapi import HTTPException
@ -414,6 +438,43 @@ def test_defaults_sanitised(tmp_path, monkeypatch):
assert data["main"] is None # la précision n'est pas une principale assert data["main"] is None # la précision n'est pas une principale
def test_defaults_dark_none_is_not_a_choice(tmp_path, monkeypatch):
"""base.dark omis/None n'est pas un choix : il reste None (le fond suit le
thème côté carte), distinct d'un choix explicite (True/False persistent)."""
import lidar_pipeline.mapserve as mapserve
_setup(tmp_path, monkeypatch, layers=("relief_oriente",))
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
req = mapserve.DefaultsRequest(main="relief_oriente",
base={"on": True, "opacity": 0.4})
saved = mapserve.set_defaults(req)["defaults"]
assert saved["base"]["dark"] is None
assert saved["v"] == 3
assert mapserve.map_meta()["default_base"]["dark"] is None
# Choix explicite : conservé tel quel
req = mapserve.DefaultsRequest(main="relief_oriente",
base={"on": True, "opacity": 0.4, "dark": True})
mapserve.set_defaults(req)
assert mapserve.map_meta()["default_base"]["dark"] is True
def test_defaults_legacy_dark_true_becomes_unset(tmp_path, monkeypatch):
"""Fichier d'avant v3 (ex-webapp, base.dark:true systématique) : servi avec
dark None (« non choisi »), jamais imposé comme choix de l'utilisateur."""
import json
import lidar_pipeline.mapserve as mapserve
_setup(tmp_path, monkeypatch, layers=("relief_oriente",))
defaults = tmp_path / ".map-defaults.json"
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", defaults)
defaults.write_text(json.dumps({
"main": "relief_oriente", "mode": "relief",
"base": {"on": True, "opacity": 0.85, "dark": True}}), encoding="utf-8")
meta = mapserve.map_meta()
assert meta["default_base"]["dark"] is None
assert meta["default_base"]["opacity"] == 0.85
def test_ui_applies_server_defaults(): def test_ui_applies_server_defaults():
"""L'interface part des défauts du serveur et sait les (re)définir.""" """L'interface part des défauts du serveur et sait les (re)définir."""
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
@ -426,6 +487,90 @@ def test_ui_applies_server_defaults():
assert "lsDel(LS_KEY)" in _MAP_JS assert "lsDel(LS_KEY)" in _MAP_JS
def test_ui_base_dark_follows_theme_unless_chosen():
"""base.dark ne vaut jamais un choix implicite : None = suit le thème.
Client : defaultState garde None (au lieu de forcer un booléen) ; loadState
ignore le base.dark d'un ancien lidarMapView_v2 (avant v3, où l'ex-webapp
persistait dark:true à chaque sauvegarde) ; saveState écrit v: 3. La classe
CSS du fond OSM part du thème courant, pas d'un « base-dark » figé."""
from lidar_pipeline.mapui import _MAP_JS
assert "dark: b.dark == null ? null : !!b.dark" in _MAP_JS
assert "if (s.v !== 3) delete sBase.dark;" in _MAP_JS
assert "lsSet(LS_KEY, Object.assign({ v: 3 }, STATE))" in _MAP_JS
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."""
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]
def test_ui_saved_print_tab_does_not_rezoom_shared_link():
"""L'onglet Export mémorisé ne recadre pas la vue au tout premier rendu du
panneau (sinon il écrase le zoom d'un lien partagé ou de l'URL initiale) :
BOOTSTRAPPING passe à faux juste après ce premier rendu."""
from lidar_pipeline.mapui import _MAP_JS
assert "let BOOTSTRAPPING = true;" in _MAP_JS
assert "printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING);" in _MAP_JS
boot = _MAP_JS[_MAP_JS.index("panelRender();\n BOOTSTRAPPING = false;"):]
assert boot.startswith("panelRender();\n BOOTSTRAPPING = false;")
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)."""
from lidar_pipeline.mapui import _MAP_JS
open_details = _MAP_JS[_MAP_JS.index("function openTileDetails("):]
assert "printSetOpen(false);" in open_details[:600]
def test_ui_colors_are_tokenised():
"""Plus de couleur en dur pour le contour de sélection, le marqueur GPS et
le journal de génération (AGENTS.md « aucune couleur en dur dans les
composants ») ; #1b1b1b reste en dur sur la poignée d'export (contraste
fixe voulu sur --print), documenté en commentaire."""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_JS
assert "color: cssVar('--tile-sel')" in _MAP_JS
assert "'#ffd24a'" not in _MAP_JS
assert "cssVar('--gps')" in _MAP_JS
assert "'#39d98a'" not in _MAP_JS
assert "--tile-sel:" in _MAP_CSS and "--gps:" in _MAP_CSS
assert ".gen-log { font:" in _MAP_CSS
gen_log = _MAP_CSS[_MAP_CSS.index(".gen-log {"):]
assert "rgba(0,0,0,0.35)" not in gen_log[:250]
assert "var(--surface-2)" in gen_log[:250] and "var(--text)" in gen_log[:250]
assert "#1b1b1b" in _MAP_CSS # volontaire, commenté juste au-dessus
def test_ui_guards_corrupt_local_storage():
"""Une valeur corrompue dans lidar-panel/lidar-print (autre appli sur la
même origine, quota dépassé en écriture…) ne casse pas l'interface :
whitelist de type/valeur plutôt qu'un usage direct de lsGet."""
from lidar_pipeline.mapui import _MAP_JS
assert "function _sanitizedPanelLS()" in _MAP_JS
panel_ls = _MAP_JS[_MAP_JS.index("function _sanitizedPanelLS("):]
assert "typeof s.tab === 'string'" in panel_ls[:700]
assert "typeof s.collapsed === 'boolean'" in panel_ls[:700]
assert "['closed', 'half', 'full'].includes(s.sheet)" in panel_ls[:700]
print_load = _MAP_JS[_MAP_JS.index("function printLoad("):]
assert "raw && typeof raw === 'object' && !Array.isArray(raw)" in print_load[:400]
def test_ui_uses_standard_tilelayer(): def test_ui_uses_standard_tilelayer():
"""L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison.""" """L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison."""
from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version

View File

@ -19,6 +19,10 @@
--gen-pending: #0b8fc0; --gen-pending: #0b8fc0;
--gen-failed: #c8452c; --gen-failed: #c8452c;
--gen-sel: var(--accent); --gen-sel: var(--accent);
/* Contour de sélection de dalle : plus sombre en thème clair (lisible sur
le fond OSM clair), jaune franc en thème sombre. */
--tile-sel: #b8860b;
--gps: #39d98a;
--fs-xs: 11px; --fs-sm: 12px; --fs-md: 13px; --fs-lg: 16px; --fs-xs: 11px; --fs-sm: 12px; --fs-md: 13px; --fs-lg: 16px;
--r-sm: 6px; --r-md: 10px; --r-full: 999px; --r-sm: 6px; --r-md: 10px; --r-full: 999px;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
@ -34,6 +38,7 @@
--border: rgba(255, 255, 255, 0.12); --text: #d9dee6; --muted: #8b94a3; --border: rgba(255, 255, 255, 0.12); --text: #d9dee6; --muted: #8b94a3;
--accent: #5aa9e6; --on-accent: #0b0e13; --track: rgba(255, 255, 255, 0.18); --accent: #5aa9e6; --on-accent: #0b0e13; --track: rgba(255, 255, 255, 0.18);
--print: #ffd166; --gen-running: #f4a261; --gen-pending: #4cc9f0; --gen-failed: #e76f51; --print: #ffd166; --gen-running: #f4a261; --gen-pending: #4cc9f0; --gen-failed: #e76f51;
--tile-sel: #ffd24a;
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 6px 24px rgba(0, 0, 0, 0.45); --shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 6px 24px rgba(0, 0, 0, 0.45);
} }
} }
@ -43,6 +48,7 @@
--border: rgba(255, 255, 255, 0.12); --text: #d9dee6; --muted: #8b94a3; --border: rgba(255, 255, 255, 0.12); --text: #d9dee6; --muted: #8b94a3;
--accent: #5aa9e6; --on-accent: #0b0e13; --track: rgba(255, 255, 255, 0.18); --accent: #5aa9e6; --on-accent: #0b0e13; --track: rgba(255, 255, 255, 0.18);
--print: #ffd166; --gen-running: #f4a261; --gen-pending: #4cc9f0; --gen-failed: #e76f51; --print: #ffd166; --gen-running: #f4a261; --gen-pending: #4cc9f0; --gen-failed: #e76f51;
--tile-sel: #ffd24a;
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 6px 24px rgba(0, 0, 0, 0.45); --shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 6px 24px rgba(0, 0, 0, 0.45);
} }
@ -188,6 +194,10 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
/* --- génération de tuiles ------------------------------------------------ */ /* --- génération de tuiles ------------------------------------------------ */
.print-msg { font-size: var(--fs-sm); margin-left: 8px; } .print-msg { font-size: var(--fs-sm); margin-left: 8px; }
/* #1b1b1b (glyphe + bordure) reste en dur volontairement : --print est un jaune/ambre
proche dans les deux thèmes (#d99a00 clair, #ffd166 sombre), un texte sombre fixe
y garde un contraste correct dans les deux cas — un token --text/--on-accent
changerait de sens (clair en thème sombre) et casserait ce contraste. */
.print-handle { display: flex; align-items: center; justify-content: center; cursor: move; .print-handle { display: flex; align-items: center; justify-content: center; cursor: move;
font-size: var(--fs-lg); color: #1b1b1b; background: var(--print); border: 2px solid #1b1b1b; font-size: var(--fs-lg); color: #1b1b1b; background: var(--print); border: 2px solid #1b1b1b;
border-radius: var(--r-full); box-shadow: 0 1px 6px rgba(0,0,0,0.5); } border-radius: var(--r-full); box-shadow: 0 1px 6px rgba(0,0,0,0.5); }
@ -198,8 +208,8 @@ body.drawing .leaflet-container { cursor: crosshair !important; }
.chk { display: flex; gap: 7px; align-items: center; font-size: var(--fs-xs); .chk { display: flex; gap: 7px; align-items: center; font-size: var(--fs-xs);
color: var(--muted); margin: 4px 0; cursor: pointer; } color: var(--muted); margin: 4px 0; cursor: pointer; }
.chk input { accent-color: var(--accent); } .chk input { accent-color: var(--accent); }
.gen-log { font: var(--fs-xs)/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--muted); .gen-log { font: var(--fs-xs)/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--text);
background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm);
padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap; padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap;
word-break: break-all; margin: 6px 0; } word-break: break-all; margin: 6px 0; }
#tools button.busy { color: var(--accent); border-color: var(--accent); } #tools button.busy { color: var(--accent); border-color: var(--accent); }

View File

@ -44,6 +44,10 @@ const PREC_LEVELS = 16;
let META = null; let META = null;
let STATE = null; let STATE = null;
// Vrai jusqu'au premier rendu du panneau au démarrage : évite qu'un onglet
// Export mémorisé recadre la vue par-dessus un lien partagé ou l'URL initiale
// (cf. printSetOpen).
let BOOTSTRAPPING = true;
// --- 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.
@ -174,7 +178,10 @@ ZoomAnimTileLayer.addInitHook(function () {
}); });
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', // Classe posée dès la création d'après le thème courant (pas un « dark » figé) :
// sinon le fond flashe sombre en thème clair le temps que /api/map/meta réponde
// (et pour de bon si l'appel échoue). applyLayers() la corrige ensuite.
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: themeIsDark() ? '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.
updateWhenZooming: false, updateWhenZooming: false,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@ -210,7 +217,9 @@ function defaultState(meta) {
precOpacity: clamp01(meta.default_precision_opacity, 0.6), precOpacity: clamp01(meta.default_precision_opacity, 0.6),
base: { on: b.on !== undefined ? !!b.on : true, base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85, opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
dark: b.dark !== undefined ? !!b.dark : null }, // null = suit le thème (choix explicite seulement si b.dark est déjà
// un booléen posé côté serveur).
dark: b.dark == null ? null : !!b.dark },
}; };
} }
@ -219,10 +228,15 @@ function loadState(meta) {
let st = def; let st = def;
const s = lsGet(LS_KEY, null); const s = lsGet(LS_KEY, null);
if (s) { if (s) {
// Ancien format (avant v3) : l'ex-webapp persistait base.dark:true à chaque
// sauvegarde, jamais un vrai choix de l'utilisateur — ignoré ici (le fond
// suit alors le thème) ; v3 seulement écrit un dark explicite (voir saveState).
const sBase = Object.assign({}, s.base || {});
if (s.v !== 3) delete sBase.dark;
st = { main: validMain(meta, s.main) || def.main, st = { main: validMain(meta, s.main) || def.main,
mode: validMode(s.mode) || def.mode, mode: validMode(s.mode) || def.mode,
precOpacity: clamp01(s.precOpacity, def.precOpacity), precOpacity: clamp01(s.precOpacity, def.precOpacity),
base: Object.assign({}, def.base, s.base || {}) }; base: Object.assign({}, def.base, sBase) };
} }
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la // Lien partagé : prime sur l'état local (les anciens liens &L=… de la
// pile de couches sont ignorés, la vue s'ouvre alors sur l'état courant). // pile de couches sont ignorés, la vue s'ouvre alors sur l'état courant).
@ -236,7 +250,9 @@ function loadState(meta) {
} }
function saveState() { function saveState() {
lsSet(LS_KEY, STATE); // v: 3 marque un format où base.dark, quand posé, est un vrai choix de
// l'utilisateur (voir loadState) — jamais mélangé à STATE lui-même.
lsSet(LS_KEY, Object.assign({ v: 3 }, STATE));
} }
// --- hash de vue ---------------------------------------------------------- // --- hash de vue ----------------------------------------------------------
@ -488,7 +504,7 @@ function selectTileOutline(data) {
if (!data || !data.corners) return; if (!data || !data.corners) return;
// Panneau dédié au-dessus de la pile LiDAR : dans l'overlayPane par défaut // Panneau dédié au-dessus de la pile LiDAR : dans l'overlayPane par défaut
// (z-index 400, comme lidarStack), le contour passait sous les tuiles. // (z-index 400, comme lidarStack), le contour passait sous les tuiles.
tileSel = L.polygon(data.corners, { pane: 'tileSel', color: '#ffd24a', weight: 2, tileSel = L.polygon(data.corners, { pane: 'tileSel', color: cssVar('--tile-sel'), weight: 2,
fill: false, dashArray: '6 4', fill: false, dashArray: '6 4',
interactive: false }).addTo(map); interactive: false }).addTo(map);
} }
@ -586,6 +602,10 @@ let tileReturnTab = null; // onglet à rouvrir après « ← Retour »
function showTilePopup(data, latlng) { function showTilePopup(data, latlng) {
lastTileQuery = latlng || lastTileQuery; 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(); if (tilePopup) tilePopup.remove();
const html = !data const html = !data
? '<div class="tile-pop">Serveur injoignable : réessayez dans un instant.</div>' ? '<div class="tile-pop">Serveur injoignable : réessayez dans un instant.</div>'
@ -614,6 +634,10 @@ function openTileDetails() {
tileReturnTab = PANEL.tab; tileReturnTab = PANEL.tab;
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = true; for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = true;
el('tileView').hidden = false; 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.sheet === 'closed') setSheet('half');
if (!isPhone() && PANEL.collapsed) setCollapsed(false); if (!isPhone() && PANEL.collapsed) setCollapsed(false);
showTileInfo(data, lastTileQuery); showTileInfo(data, lastTileQuery);
@ -627,12 +651,22 @@ function hideTileView() {
if (tileSel) { map.removeLayer(tileSel); tileSel = null; } if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
} }
// 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();
panelRender();
}
// Ferme la bulle, sinon la fiche ; renvoie true si quelque chose a été fermé. // Ferme la bulle, sinon la fiche ; renvoie true si quelque chose a été fermé.
function closeTileViews() { function closeTileViews() {
if (tilePopup) { tilePopup.remove(); tilePopup = null; return true; } if (tilePopup) { tilePopup.remove(); tilePopup = null; return true; }
if (!el('tileView').hidden) { if (!el('tileView').hidden) {
hideTileView(); restoreTabAfterDetails();
openTab(tileReturnTab || 'view');
return true; return true;
} }
return false; return false;
@ -1039,12 +1073,18 @@ function printSave() {
} }
function printLoad() { function printLoad() {
const s = lsGet(PRINT_LS, {}); const raw = lsGet(PRINT_LS, {});
// Valeur corrompue (clé partagée avec une autre appli, quota dépassé
// pendant l'écriture…) : objet non conforme ignoré plutôt que planté.
const s = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'], for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'],
['scale', 'printScale'], ['title', 'printTitle']]) { ['scale', 'printScale'], ['title', 'printTitle']]) {
if (s[k] != null) el(id).value = s[k]; if (typeof s[k] === 'string') el(id).value = s[k];
}
if (Array.isArray(s.center) && s.center.length === 2 &&
s.center.every((n) => typeof n === 'number' && Number.isFinite(n))) {
PRINT.center = L.latLng(s.center);
} }
if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center);
} }
// Recadre sur le cadre en laissant libre la place du panneau (colonne à // Recadre sur le cadre en laissant libre la place du panneau (colonne à
@ -1122,13 +1162,16 @@ function printCenterHere() {
// Cadre affiché tant que l'onglet Export PDF est visible (événement panel:tab, // Cadre affiché tant que l'onglet Export PDF est visible (événement panel:tab,
// émis à chaque rendu du panneau : on ne fait rien si l'état est inchangé). // émis à chaque rendu du panneau : on ne fait rien si l'état est inchangé).
function printSetOpen(on) { // fit : recadrer la vue sur le cadre une fois posé — faux au tout premier
// rendu (onglet Export mémorisé) pour ne pas écraser le zoom d'un lien
// partagé ou de l'URL au chargement.
function printSetOpen(on, fit) {
if (PRINT.on === on) return; if (PRINT.on === on) return;
PRINT.on = on; PRINT.on = on;
if (on) { if (on) {
// cadre gardé s'il est encore dans la vue, sinon posé au centre // cadre gardé s'il est encore dans la vue, sinon posé au centre
if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter(); if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter();
printRefreshFrame(true); printRefreshFrame(fit !== false);
} else { } else {
if (PRINT.rect) PRINT.rect.remove(); if (PRINT.rect) PRINT.rect.remove();
if (PRINT.handle) PRINT.handle.remove(); if (PRINT.handle) PRINT.handle.remove();
@ -1174,13 +1217,25 @@ el('printTitle').addEventListener('change', printSave);
printLoad(); printLoad();
document.addEventListener('panel:tab', (e) => { document.addEventListener('panel:tab', (e) => {
printSetOpen(e.detail.visible === 'print'); printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING);
if (e.detail.visible !== 'gen' && genDrawing) genSetDrawing(false); if (e.detail.visible !== 'gen' && genDrawing) genSetDrawing(false);
}); });
// --- panneau à onglets -------------------------------------------------------- // --- panneau à onglets --------------------------------------------------------
const PANEL_LS = 'lidar-panel'; const PANEL_LS = 'lidar-panel';
const PANEL = Object.assign({ tab: 'view', collapsed: false, sheet: 'half' }, lsGet(PANEL_LS, {})); // Ne retient que les clés connues, du bon type et (pour sheet) dans la liste
// valide : une valeur corrompue casserait sinon le CSS (panel.dataset.sheet
// inconnu de map.css) ou l'onglet affiché.
function _sanitizedPanelLS() {
const raw = lsGet(PANEL_LS, {});
const s = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const out = {};
if (typeof s.tab === 'string') out.tab = s.tab;
if (typeof s.collapsed === 'boolean') out.collapsed = s.collapsed;
if (['closed', 'half', 'full'].includes(s.sheet)) out.sheet = s.sheet;
return out;
}
const PANEL = Object.assign({ tab: 'view', collapsed: false, sheet: 'half' }, _sanitizedPanelLS());
// want = onglet choisi (mémorisé) ; tab = onglet affiché. Génération encore // want = onglet choisi (mémorisé) ; tab = onglet affiché. Génération encore
// inconnue au démarrage (api/status) : Affichage en attendant, puis l'onglet // inconnue au démarrage (api/status) : Affichage en attendant, puis l'onglet
// choisi revient dès qu'il est proposé. // choisi revient dès qu'il est proposé.
@ -1314,6 +1369,7 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
renderPanel(); renderPanel();
renderBaseRow(); renderBaseRow();
panelRender(); panelRender();
BOOTSTRAPPING = false;
renderUseCard(); renderUseCard();
genFillViz(); genFillViz();
// Droits de génération : boutons affichés si le serveur peut générer // Droits de génération : boutons affichés si le serveur peut générer
@ -1449,7 +1505,7 @@ el('gps').addEventListener('click', () => {
map.setView(ll, Math.max(map.getZoom(), 16)); map.setView(ll, Math.max(map.getZoom(), 16));
if (window._gpsMarker) window._gpsMarker.remove(); if (window._gpsMarker) window._gpsMarker.remove();
window._gpsMarker = L.circleMarker(ll, { window._gpsMarker = L.circleMarker(ll, {
radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9 radius: 6, color: cssVar('--gps'), fillColor: cssVar('--gps'), fillOpacity: 0.9
}).addTo(map); }).addTo(map);
}, (err) => { }, (err) => {
btn.classList.remove('busy'); btn.classList.remove('busy');