diff --git a/AGENTS.md b/AGENTS.md index d133b75..a34601e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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), `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`). - **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). diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index 63dfe8f..6701fc0 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -911,11 +911,15 @@ def _sanitize_defaults(req): base_in = req.base if isinstance(req.base, dict) else {} base = {"on": bool(base_in.get("on", True)), "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, "mode": req.mode if req.mode in VIEW_MODES else "relief", "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): @@ -974,6 +978,11 @@ def map_meta(): if main not in keys or main == PRECISION_VIZ: main = default_main_layer(keys) 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 { "layers": infos, "default_main": main, @@ -981,7 +990,7 @@ def map_meta(): "default_mode": mode, "default_precision_opacity": _clamp01(saved.get("precision_opacity"), DEFAULT_PRECISION_OPACITY), - "default_base": saved.get("base"), + "default_base": base_default, "defaults_saved": bool(saved), # 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. @@ -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 "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 for t in scan_tiles(OUTPUT_DIR / "visualisations"): if (t["col"], t["row"]) != (col, row): continue if best is None or t["resolution"] < best["resolution"]: 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: return {"found": False, **base} meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM") diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 78797a0..2a38b89 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -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 +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): """Sonde de santé et assets de l'interface (revalidés, jamais immuables).""" 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 +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(): """L'interface part des défauts du serveur et sait les (re)définir.""" 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 +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(): """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 diff --git a/lidar_pipeline/web/map.css b/lidar_pipeline/web/map.css index 10978de..a259a7b 100644 --- a/lidar_pipeline/web/map.css +++ b/lidar_pipeline/web/map.css @@ -19,6 +19,10 @@ --gen-pending: #0b8fc0; --gen-failed: #c8452c; --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; --r-sm: 6px; --r-md: 10px; --r-full: 999px; --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; --accent: #5aa9e6; --on-accent: #0b0e13; --track: rgba(255, 255, 255, 0.18); --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); } } @@ -43,6 +48,7 @@ --border: rgba(255, 255, 255, 0.12); --text: #d9dee6; --muted: #8b94a3; --accent: #5aa9e6; --on-accent: #0b0e13; --track: rgba(255, 255, 255, 0.18); --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); } @@ -188,6 +194,10 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius /* --- génération de tuiles ------------------------------------------------ */ .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; 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); } @@ -198,8 +208,8 @@ body.drawing .leaflet-container { cursor: crosshair !important; } .chk { display: flex; gap: 7px; align-items: center; font-size: var(--fs-xs); color: var(--muted); margin: 4px 0; cursor: pointer; } .chk input { accent-color: var(--accent); } -.gen-log { font: var(--fs-xs)/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--muted); - background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: var(--r-sm); +.gen-log { font: var(--fs-xs)/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--text); + 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; word-break: break-all; margin: 6px 0; } #tools button.busy { color: var(--accent); border-color: var(--accent); } diff --git a/lidar_pipeline/web/map.js b/lidar_pipeline/web/map.js index f08d885..3977237 100644 --- a/lidar_pipeline/web/map.js +++ b/lidar_pipeline/web/map.js @@ -44,6 +44,10 @@ const PREC_LEVELS = 16; let META = 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 ------------------------------------------------------------------ // 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', { - 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. updateWhenZooming: false, attribution: '© OpenStreetMap' @@ -210,7 +217,9 @@ function defaultState(meta) { precOpacity: clamp01(meta.default_precision_opacity, 0.6), base: { on: b.on !== undefined ? !!b.on : true, 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; const s = lsGet(LS_KEY, null); 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, mode: validMode(s.mode) || def.mode, 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 // pile de couches sont ignorés, la vue s'ouvre alors sur l'état courant). @@ -236,7 +250,9 @@ function loadState(meta) { } 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 ---------------------------------------------------------- @@ -488,7 +504,7 @@ function selectTileOutline(data) { if (!data || !data.corners) return; // 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. - 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', interactive: false }).addTo(map); } @@ -586,6 +602,10 @@ let tileReturnTab = null; // onglet à rouvrir après « ← Retour » function showTilePopup(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 ? '