diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py
index 01aa188..94b3087 100644
--- a/lidar_pipeline/mapui.py
+++ b/lidar_pipeline/mapui.py
@@ -1,9 +1,9 @@
"""Interface de la carte à tuiles XYZ (image lidar-maps).
-Même convention que `index.py` : le CSS et le JS vivent dans des constantes,
-sont écrits par `write_map_assets()` et bâchés dans l'image au build. La page
-est une coquille minimale : tout l'état vient de `/api/map/meta`, les couches
-sont des `L.tileLayer` standard — le navigateur gère LOD, cache et animation.
+Le HTML, le CSS et le JS vivent dans `web/` (vrais fichiers, relus à l'import) ;
+`write_map_assets()` les bâche dans l'image au build. La page est une coquille
+minimale : tout l'état vient de `/api/map/meta`, les couches sont des `L.tileLayer`
+standard — le navigateur gère LOD, cache et animation.
"""
import hashlib
@@ -11,6 +11,18 @@ from pathlib import Path
ASSETS_DIRNAME = "mapserve_assets"
+WEB_DIR = Path(__file__).resolve().parent / "web"
+
+
+def _read_web(name):
+ """Fichier de l'interface (HTML/CSS/JS), lu une fois à l'import."""
+ return (WEB_DIR / name).read_text(encoding="utf-8")
+
+
+_MAP_HTML = _read_web("map.html")
+_MAP_CSS = _read_web("map.css")
+_MAP_JS = _read_web("map.js")
+
def ui_version():
"""Hash court du CSS+JS : sert d'URL de version pour les assets."""
@@ -38,1605 +50,3 @@ def render_html():
return _MAP_HTML.replace("__ASSETS_V__", ui_version())
-_MAP_HTML = """
-
-
-
-
-Carte LiDAR
-
-
-
-
-
-
-
-
-
- ▾
- Couches
-
-
-
-
-
-
-
Fond OpenStreetMap
-
-
-
-
-
-
- Affichage
-
-
-
-
-
- Relief
- Précision
- Les deux
-
-
- Opacité de la précision
-
-
-
-
-
-
≤ 0,35 4 ≥ 45 pts/m²
-
Points sol par m² : sombre = relief interpolé, noir = aucun point
-
-
-
-
-
-
-
-
-
-
-
-
Pente orientée vers…clair = bosse · sombre = creux
-
-
-
-
- Fond d'imagerie XYZ
- ×
-
-
-
-
-
-
- Export PDF
-
- ×
-
-
-
- A4 A3
-
- Paysage Portrait
-
- 1:1 000 1:2 000
- 1:5 000 1:10 000
-
-
Faites glisser la poignée ✥ pour placer le cadre jaune sur la zone à imprimer.
-
⌖ Centrer ici
- ⤢ Voir le cadre
-
-
-
-
-
-
- Génération
-
- ×
-
-
-
Dessinez une zone (+) ou complétez les dalles existantes (⤒).
-
-
Couches
-
-
Régénérer les tuiles existantes
-
Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)
-
-
-
-
-
-
-
-
- + Zone
- ⤒ Compléter
-
-
- +
- −
- ⤢
- ⌖
- ⎙
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
-
-_MAP_CSS = """/* Carte LiDAR — tuiles XYZ, habillage minimal type carte grand public. */
-:root {
- color-scheme: dark;
- --bg: #0b0e13;
- --card: rgba(16, 20, 27, 0.94);
- --border: rgba(255, 255, 255, 0.10);
- --text: #d9dee6;
- --muted: #8b94a3;
- --accent: #5aa9e6;
- --radius: 12px;
-}
-* { box-sizing: border-box; margin: 0; padding: 0; }
-[hidden] { display: none !important; }
-html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
- font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
-#map { position: absolute; inset: 0; background: var(--bg); }
-.leaflet-container { background: var(--bg); font: inherit; }
-/* Fond OSM assombri pour laisser ressortir les rendus LiDAR. Filtre posé sur
- le conteneur de la couche, pas sur chaque tuile : même rendu (opérations
- par pixel), mais une seule passe de filtre au lieu d'une par tuile —
- −30 % de CPU (moteur de rendu + GPU) en déplacement, mesuré sous Chrome. */
-.leaflet-tile-pane .leaflet-layer.base-dark { filter: brightness(0.38) contrast(1.1) saturate(0.7); }
-
-.card { position: fixed; z-index: 900; background: var(--card); color: var(--text);
- border: 1px solid var(--border); border-radius: var(--radius);
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
- backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
-#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
-#tilecard { left: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px);
- max-height: calc(100vh - 24px); overflow-y: auto; }
-.info-sec { margin-top: 10px; }
-.info-sec h4 { margin: 0 0 4px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;
- text-transform: uppercase; color: var(--muted); }
-.info-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; font-size: 12.5px; }
-.info-kv dt { color: var(--muted); }
-.info-kv dd { margin: 0; overflow-wrap: anywhere; }
-.info-dl { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: 6px;
- background: rgba(90, 160, 255, 0.18); color: var(--text); text-decoration: none; font-weight: 600; }
-.info-dl:hover { background: rgba(90, 160, 255, 0.3); }
-#rose { right: 12px; bottom: 190px; padding: 8px 8px 6px; display: flex; flex-direction: column;
- align-items: center; gap: 2px; z-index: 800; }
-#rose canvas { width: 132px; height: 132px; }
-.rose-cap { font-size: 11px; text-align: center; color: var(--text); line-height: 1.3; }
-.rose-cap span { color: var(--muted); font-size: 10.5px; }
-#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: 910; }
-.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
-.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
-.card-stat { margin-left: auto; font-size: 11px; color: var(--muted); }
-.card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; }
-#layercard.collapsed .card-body { display: none; }
-#layercard.collapsed #cardToggle { transform: rotate(-90deg); }
-.card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); }
-
-button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
- border-radius: 8px; padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
-button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
-.icon-btn { border: none; background: none; color: var(--muted); padding: 2px 6px; font-size: 14px; }
-.icon-btn:hover { color: var(--text); background: none; }
-.link-btn { flex: 1; font-size: 11.5px; }
-
-/* Ligne du fond de carte : œil, nom, opacité. */
-.layer-row { display: grid; grid-template-columns: 14px 24px minmax(0, 1fr) auto 34px;
- align-items: center; gap: 6px; padding: 5px 4px; border-radius: 8px; user-select: none; }
-.layer-ctl { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 84px;
- gap: 6px; align-items: center; padding: 2px 0 2px 38px; }
-.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
-/* Affichage : une couche principale, la précision seule ou superposée. */
-.view-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center;
- gap: 8px; padding: 8px 4px 4px; border-top: 1px solid var(--border); margin-top: 4px; }
-.view-label { font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
-.view-row .layer-name { color: var(--text); }
-.view-sel { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
- border-radius: 6px; padding: 2px 4px; font: inherit; font-size: 12px; min-width: 0; width: 100%; height: 24px; }
-.view-sel option { background-color: #10141b; color: var(--text); }
-.mode-seg { display: grid; grid-template-columns: repeat(3, 1fr); margin: 6px 4px 2px;
- border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
-.mode-seg button { border: none; border-radius: 0; background: rgba(255, 255, 255, 0.04);
- color: var(--muted); padding: 6px 4px; font-size: 12px; }
-.mode-seg button + button { border-left: 1px solid var(--border); }
-.mode-seg button:hover { color: var(--text); background: rgba(255, 255, 255, 0.1); }
-.mode-seg button[aria-pressed="true"] { background: var(--accent); color: #0b0e13; font-weight: 600; }
-.mode-seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
-.prec-ctl { display: grid; grid-template-columns: auto minmax(0, 1fr) 34px; gap: 8px;
- align-items: center; padding: 6px 4px 0; font-size: 11.5px; color: var(--muted); }
-.prec-legend { padding: 8px 4px 2px; }
-.prec-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px;
- border: 1px solid var(--border); border-radius: 3px; overflow: hidden; }
-.prec-ticks { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--muted);
- font-variant-numeric: tabular-nums; margin-top: 2px; }
-.prec-cap { font-size: 10.5px; color: var(--muted); margin-top: 2px; }
-.grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; }
-.grip::before { content: '⠿'; }
-#baseRow .grip::before { content: ''; }
-.eye { width: 24px; height: 24px; padding: 0; border: none; background: none; color: var(--muted);
- display: flex; align-items: center; justify-content: center; }
-.eye.on { color: var(--accent); }
-.eye svg { width: 16px; height: 16px; }
-.layer-name { font-size: 12.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.layer-row.on .layer-name { color: var(--text); }
-.pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
-input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
- background: transparent; cursor: pointer; }
-input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
-input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%;
- background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; }
-input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
-input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; }
-
-#tools { position: fixed; right: 12px; bottom: 12px; z-index: 900; display: flex; flex-direction: column; gap: 6px; }
-#tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card);
- border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
-#gps.busy { color: var(--accent); border-color: var(--accent); }
-/* Indicateur de chargement : tuiles restantes pour finir la vue courante
- (fond + LiDAR), barre de progression du lot en cours. */
-.tileload { position: fixed; left: 50%; top: 12px; transform: translateX(-50%); z-index: 905;
- display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: 999px;
- background: var(--card); border: 1px solid var(--border); font-size: 12px;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); pointer-events: none;
- transition: opacity 0.3s ease; white-space: nowrap; }
-.tileload.done { opacity: 0; }
-.tl-spin { width: 12px; height: 12px; border-radius: 50%; flex: none;
- border: 2px solid rgba(90, 169, 230, 0.25); border-top-color: var(--accent);
- animation: tlspin 0.8s linear infinite; }
-.tileload.done .tl-spin { animation: none; border-color: var(--accent); }
-@keyframes tlspin { to { transform: rotate(360deg); } }
-.tl-bar { width: 60px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.1);
- overflow: hidden; flex: none; }
-.tl-bar span { display: block; height: 100%; width: 0; background: var(--accent);
- transition: width 0.2s ease; }
-.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
- background: var(--card); border: 1px solid var(--border); border-radius: 8px;
- padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; }
-
-.meta { font-size: 11.5px; color: var(--muted); margin: 2px 0; }
-.meta b { color: var(--text); font-weight: 600; }
-.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
- background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 6px;
- padding: 6px 8px; word-break: break-all; margin: 4px 0 8px; }
-.use-row { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; }
-.use-row select { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
- border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 12px; }
-.use-note { font-size: 11px; color: var(--muted); line-height: 1.5; }
-.leaflet-control-attribution { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; }
-.leaflet-control-attribution a { color: var(--accent) !important; }
-
-/* --- génération de tuiles ------------------------------------------------ */
-#gencard { right: 12px; top: 56px; width: 340px; max-width: calc(100vw - 24px); }
-#printcard { right: 60px; bottom: 12px; width: 340px; max-width: calc(100vw - 84px); }
-#printTitle { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
- border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; }
-.print-hint { margin: 4px 0; font-size: 12px; opacity: 0.75; }
-.print-msg { font-size: 12px; margin-left: 8px; }
-.print-handle { display: flex; align-items: center; justify-content: center; cursor: move;
- font-size: 20px; color: #1b1b1b; background: #ffd166; border: 2px solid #1b1b1b;
- border-radius: 50%; box-shadow: 0 1px 6px rgba(0,0,0,0.5); }
-body.drawing { cursor: crosshair !important; }
-body.drawing .leaflet-container { cursor: crosshair !important; }
-/* Barre de génération : en HAUT à droite de l'écran, boutons accentués —
- c'est l'entrée principale du traitement, elle ne se confond pas avec les
- icônes de navigation (zoom/GPS) du coin bas droit. */
-#gentools { position: fixed; top: 12px; right: 12px; z-index: 900; display: flex; gap: 8px; }
-#gentools button { background: var(--card); color: var(--accent); border: 1px solid var(--accent);
- border-radius: 10px; padding: 8px 14px; font: inherit; font-size: 13px; font-weight: 600;
- box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
-#gentools button:hover { background: var(--accent); color: #0b0e13; }
-#gentools button.accent { background: var(--accent); color: #0b0e13; }
-.gen-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px;
- align-items: center; margin: 4px 0; font-size: 11.5px; color: var(--muted); }
-#genViz { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border);
- border-radius: 6px; padding: 3px 4px; font: inherit; font-size: 11.5px; }
-#genViz option { background-color: #10141b; }
-#genClass, .gen-input { background: rgba(255,255,255,0.06); color: var(--text);
- border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px;
- font: inherit; font-size: 11.5px; width: 100%; height: 24px; }
-.chk { display: flex; gap: 7px; align-items: center; font-size: 11.5px;
- color: var(--muted); margin: 4px 0; cursor: pointer; }
-.chk input { accent-color: var(--accent); }
-button.primary { background: var(--accent); border-color: var(--accent); color: #0b0e13; font-weight: 600; }
-button.primary:hover { filter: brightness(1.12); }
-.gen-log { font: 10.5px/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: 6px;
- padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap;
- word-break: break-all; margin: 6px 0; }
-#tools button.accent { color: var(--accent); border-color: var(--accent); }
-#tools button.busy { color: var(--accent); border-color: var(--accent); }
-/* Cadres des dalles du run : orange pulsant = rendu en cours, bleu = en
- attente, rouge = échec. Les dalles faites n'ont pas de cadre. */
-@keyframes genpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
-.gen-frame-running { stroke: #f4a261; fill: #f4a26122; animation: genpulse 1.4s ease-in-out infinite; }
-.gen-frame-pending { stroke: #4cc9f0; fill: #4cc9f014; stroke-dasharray: 6 4; }
-.gen-frame-failed { stroke: #e76f51; fill: #e76f5133; }
-.gen-sel { stroke: var(--accent); fill: #5aa9e61f; cursor: pointer; }
-@media (max-width: 720px) {
- #layercard { width: calc(100vw - 24px); }
- #tilecard { width: calc(100vw - 24px); }
- #gencard { width: calc(100vw - 24px); }
- #printcard { width: calc(100vw - 84px); }
-}
-"""
-
-
-_MAP_JS = r"""'use strict';
-/* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation.
- Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier
- d'image, aucune rotation CSS, aucune mosaïque maison. */
-
-const UI_VERSION = "__UI_VERSION__";
-const LS_KEY = 'lidarMapView_v2';
-const el = (id) => document.getElementById(id);
-const SVG_EYE = ' ';
-const SVG_EYE_OFF = ' ';
-
-// Affichage : une couche principale (relief orienté) et la couche
-// « précision » (densité de points sol), montrée seule ou superposée en
-// « produit » (mix-blend-mode multiply) sur la principale : les zones peu
-// fiables s'assombrissent sans masquer le relief. Touche P : mode suivant.
-const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['both', 'Les deux']];
-const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
-const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
-const clamp01 = (v, d) => {
- const n = Number(v);
- return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : d;
-};
-// Paliers de la précision : niveau k à partir de 0,25 × 2^(k/2) pts/m²
-// (visualizations.density_levels), gris = colormap « gray » sur 0..15.
-const PREC_LEVELS = 16;
-
-let META = null;
-let STATE = null;
-const LAYERS = new Map(); // clé → L.TileLayer
-
-const map = L.map('map', {
- zoomControl: false,
- attributionControl: true,
- minZoom: 3,
- // Zoom plafonné au natif (19 = 0,2 m/px, 1 px écran = 1 px LiDAR) : toute
- // la pyramide est générée d'avance, jamais de tuile agrandie ni étirée.
- maxZoom: 19,
- zoomSnap: 0.5,
- wheelPxPerZoomLevel: 90,
- worldCopyJump: false,
- preferCanvas: false
-});
-map.attributionControl.setPrefix('');
-L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
-
-// Pendant l'animation de zoom, transformations SEULES (aucun niveau créé,
-// tuiles chargées à la fin) : un niveau neuf n'est pas composé par Chrome
-// (vide au départ, il est animé sur le thread principal, gelé ~90 ms par le
-// chargement). Le fond sautait alors que le relief orienté, qui garde son
-// niveau pair, glissait normalement — les deux couches se désynchronisaient.
-// Leaflet 1.9 déclenche aussi « zoom » dès le DÉBUT de l'animation : même
-// garde dans _resetView tant que la carte anime.
-const ZoomAnimTileLayer = L.TileLayer.extend({
- _animateZoom(e) { this._setView(e.center, e.zoom, true, true); },
- _resetView(e) {
- if (this._map && this._map._animatingZoom) {
- this._setView(this._map.getCenter(), this._map.getZoom(), true, true);
- return;
- }
- L.TileLayer.prototype._resetView.call(this, e);
- }
-});
-
-// --- chargement des tuiles -------------------------------------------------
-// Compteur recalculé depuis l'état réel des tuiles de chaque couche (tuiles
-// du niveau courant pas encore chargées) plutôt que par +1/−1 sur les
-// événements : une tuile retirée avant la fin de son chargement (déplacement
-// rapide) ne déclenche ni « tileload » ni « tileerror », un compteur par
-// événements dériverait. Lot : de la première tuile en attente au retour à
-// zéro ; la barre donne la part déjà chargée du lot.
-const TILE_LOAD = { layers: new Set(), batch: 0, done: 0, raf: 0, hideT: 0 };
-
-function tilesPending() {
- let n = 0;
- for (const layer of TILE_LOAD.layers) {
- if (!layer._map) continue;
- for (const key in layer._tiles) {
- const t = layer._tiles[key];
- if (t.current && !t.loaded) n += 1;
- }
- }
- return n;
-}
-
-function updateTileLoad() {
- TILE_LOAD.raf = 0;
- const box = el('tileload');
- if (!box) return;
- const pending = tilesPending();
- if (pending > 0) {
- clearTimeout(TILE_LOAD.hideT);
- TILE_LOAD.hideT = 0;
- if (TILE_LOAD.batch === 0) TILE_LOAD.done = 0;
- TILE_LOAD.batch = Math.max(TILE_LOAD.batch, TILE_LOAD.done + pending);
- el('tileloadText').textContent = pending + (pending > 1 ? ' tuiles restantes' : ' tuile restante');
- el('tileloadBar').style.width = Math.round(100 * TILE_LOAD.done / TILE_LOAD.batch) + '%';
- box.classList.remove('done');
- box.hidden = false;
- } else if (TILE_LOAD.batch > 0 && !TILE_LOAD.hideT) {
- TILE_LOAD.batch = 0;
- el('tileloadText').textContent = 'Vue chargée';
- el('tileloadBar').style.width = '100%';
- box.classList.add('done');
- TILE_LOAD.hideT = setTimeout(() => { box.hidden = true; TILE_LOAD.hideT = 0; }, 600);
- }
-}
-
-function scheduleTileLoad() {
- if (!TILE_LOAD.raf) TILE_LOAD.raf = requestAnimationFrame(updateTileLoad);
-}
-
-ZoomAnimTileLayer.addInitHook(function () {
- TILE_LOAD.layers.add(this);
- const onDone = () => { TILE_LOAD.done += 1; scheduleTileLoad(); };
- this.on('tileload', onDone);
- this.on('tileerror', onDone);
- this.on('tileloadstart tileunload load add remove', scheduleTileLoad);
-});
-
-const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
- maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
- // Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
- updateWhenZooming: false,
- attribution: '© OpenStreetMap '
-}).addTo(map);
-
-function toast(msg, ms) {
- const t = el('toast');
- t.textContent = msg;
- t.hidden = false;
- clearTimeout(toast._t);
- toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500);
-}
-
-// --- état persisté --------------------------------------------------------
-function precisionKey() {
- const p = META && META.precision_layer;
- return p && META.layers.some(l => l.key === p) ? p : null;
-}
-
-function mainKeys(meta) {
- return meta.layers.map(l => l.key).filter(k => k !== meta.precision_layer);
-}
-
-function validMain(meta, k) {
- return mainKeys(meta).includes(k) ? k : null;
-}
-
-function defaultState(meta) {
- const b = meta.default_base || {};
- return {
- main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
- mode: validMode(meta.default_mode) || 'relief',
- 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 : true },
- };
-}
-
-function loadState(meta) {
- const def = defaultState(meta);
- let st = def;
- try {
- const raw = localStorage.getItem(LS_KEY);
- if (raw) {
- const s = JSON.parse(raw);
- 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 || {}) };
- }
- } catch (e) { st = def; }
- // 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).
- const hash = parseHash();
- if (hash) {
- if (hash.main && validMain(meta, hash.main)) st.main = hash.main;
- if (hash.view) { st.mode = hash.view.mode; st.precOpacity = hash.view.opacity; }
- if (hash.base) st.base = hash.base;
- }
- return st;
-}
-
-function saveState() {
- try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
-}
-
-// --- hash de vue ----------------------------------------------------------
-// #z/lat/lng&M=principale&P=mode:opacité&B=on:opacité:sombre
-function parseHash() {
- const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
- if (!m) return null;
- const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
- main: null, view: null, base: null };
- const seg = {};
- for (const part of m[4].split('&')) {
- const i = part.indexOf('=');
- if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
- }
- if (seg.M) out.main = seg.M;
- if (seg.P) {
- const f = seg.P.split(':');
- const mode = validMode(f[0]);
- if (mode) out.view = { mode, opacity: clamp01(Number(f[1]) / 100, 0.6) };
- }
- if (seg.B) {
- const f = seg.B.split(':');
- if (f.length === 3) {
- const o = Number(f[1]);
- out.base = { on: f[0] === '1', opacity: Number.isFinite(o) ? o / 100 : 0.85, dark: f[2] === '1' };
- }
- }
- return out;
-}
-
-function viewHash(full) {
- const c = map.getCenter();
- let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6);
- if (full) {
- if (STATE.main) h += '&M=' + STATE.main;
- h += '&P=' + STATE.mode + ':' + Math.round(STATE.precOpacity * 100);
- h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
- + ':' + (STATE.base.dark ? '1' : '0');
- }
- return h;
-}
-
-let hashTimer = null;
-function saveHashSoon() {
- clearTimeout(hashTimer);
- hashTimer = setTimeout(() => {
- history.replaceState(null, '', location.pathname + location.search + viewHash(false));
- }, 350);
-}
-
-// --- couches --------------------------------------------------------------
-function tileUrl(key) {
- return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
-}
-
-// Couche à niveaux standard pairs seulement (stockage réduit côté serveur) :
-// à un zoom impair, les tuiles du niveau pair SUPÉRIEUR sont affichées
-// réduites de moitié (nettes). Exception : le niveau natif lui-même (19,
-// impair — 0,2 m/px) est demandé tel quel, rendu à la volée par le serveur.
-// Le replier sur le niveau pair inférieur plafonnait l'affichage à 0,4 m/px,
-// étiré ensuite par le navigateur : la pleine résolution n'était jamais vue.
-// Au-delà du natif, Leaflet étire le niveau natif (jamais de tuile agrandie
-// côté serveur). Le niveau de tuile Leaflet est ici la numérotation standard
-// (zoomOffset −1 avec des tuiles de 512 px).
-const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
- _clampZoom(zoom) {
- let z = L.TileLayer.prototype._clampZoom.call(this, zoom);
- const maxN = this.options.maxNativeZoom;
- if (this.options.evenLevels && z % 2 && (maxN === undefined || z < maxN)) z += 1;
- return z;
- }
-});
-
-function buildLayers() {
- // Conteneur isolé : `isolation: isolate` cantonne le « produit » de la
- // précision à la couche principale. Sans lui, il assombrirait aussi le fond
- // OSM. Les panneaux sont réutilisés si déjà créés (rafraîchissement après
- // un run de génération).
- const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
- stack.style.zIndex = 400;
- stack.style.isolation = 'isolate';
- META.layers.forEach((info) => {
- const paneName = 'lidar-' + info.key;
- const pane = map.getPane(paneName) || map.createPane(paneName, stack);
- const existing = LAYERS.get(info.key);
- if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); }
- const layer = new EvenLevelTileLayer(tileUrl(info.key), {
- evenLevels: !!META.even_levels,
- pane: paneName,
- // Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
- // pas de requêtes inutiles sur le reste du monde.
- bounds: META.bounds
- ? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]])
- : undefined,
- tileSize: META.tile_size,
- zoomOffset: META.zoom_offset,
- minNativeZoom: META.min_zoom,
- maxNativeZoom: META.max_native_zoom,
- minZoom: 0,
- maxZoom: 19,
- // Tuiles de 512 px : une rangée de réserve hors écran vaut les deux
- // rangées de 256 px du fond. Deux rangées doublaient les images
- // décodées gardées en mémoire (1 Mo chacune) pour peu de gain.
- keepBuffer: 1,
- updateWhenZooming: false,
- crossOrigin: false,
- errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
- attribution: META.attribution
- });
- LAYERS.set(info.key, layer);
- });
- applyLayers();
-}
-
-// Mode réellement appliqué : sans couche précision servie, relief seul.
-function effectiveMode() {
- return precisionKey() ? STATE.mode : 'relief';
-}
-
-function applyLayers() {
- const prec = precisionKey();
- const mode = effectiveMode();
- for (const [key, layer] of LAYERS) {
- const pane = map.getPane('lidar-' + key);
- let show, opacity = 1, blend = 'normal';
- if (key === prec) {
- show = mode !== 'relief';
- if (mode === 'both') { opacity = STATE.precOpacity; blend = 'multiply'; }
- } else {
- show = key === STATE.main && mode !== 'precision';
- }
- if (pane) {
- pane.style.zIndex = key === prec ? '402' : '401';
- pane.style.mixBlendMode = blend;
- }
- if (show) {
- if (!map.hasLayer(layer)) layer.addTo(map);
- layer.setOpacity(opacity);
- } else if (map.hasLayer(layer)) {
- map.removeLayer(layer);
- }
- }
- if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
- else if (map.hasLayer(base)) map.removeLayer(base);
- base.setOpacity(STATE.base.opacity);
- const container = base.getContainer && base.getContainer();
- if (container) container.classList.toggle('base-dark', STATE.base.dark);
- updateRose();
-}
-
-function setMode(mode, announce) {
- if (!validMode(mode)) return;
- STATE.mode = mode;
- saveState(); renderPanel(); applyLayers();
- if (announce) toast('Affichage : ' + VIEW_MODES.find(m => m[0] === mode)[1], 1200);
-}
-
-function renderPrecLegend() {
- const bar = el('precBar');
- if (bar.childElementCount) return;
- for (let k = 0; k < PREC_LEVELS; k++) {
- const g = Math.round(255 * k / (PREC_LEVELS - 1));
- const lo = 0.25 * Math.pow(2, k / 2);
- const hi = 0.25 * Math.pow(2, (k + 1) / 2);
- const cell = document.createElement('i');
- cell.style.background = 'rgb(' + g + ',' + g + ',' + g + ')';
- cell.title = k === 0 ? '≤ ' + hi.toFixed(2).replace('.', ',') + ' pt/m²'
- : k === PREC_LEVELS - 1 ? '≥ ' + Math.round(lo) + ' pts/m²'
- : lo.toFixed(lo < 10 ? 1 : 0).replace('.', ',') + '–'
- + hi.toFixed(hi < 10 ? 1 : 0).replace('.', ',') + ' pts/m²';
- bar.appendChild(cell);
- }
-}
-
-function renderPanel() {
- const labels = new Map(META.layers.map(l => [l.key, l]));
- const mains = mainKeys(META);
- const info = labels.get(STATE.main);
- const name = el('mainName'), sel = el('mainSel');
- name.textContent = info ? info.label : 'Aucune couche';
- name.title = info ? (info.description || info.label) : '';
- // Plusieurs couches principales possibles : menu ; sinon simple libellé.
- const many = mains.length > 1;
- name.hidden = many;
- sel.hidden = !many;
- if (many) {
- sel.innerHTML = mains.map(k => '' + labels.get(k).label + ' ').join('');
- }
- const prec = precisionKey();
- el('precBlock').hidden = !prec;
- if (!prec) return;
- renderPrecLegend();
- const mode = effectiveMode();
- el('modeSeg').querySelectorAll('button').forEach(b =>
- b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
- el('precCtl').hidden = mode !== 'both';
- el('precOpacity').value = Math.round(STATE.precOpacity * 100);
- el('precPct').textContent = Math.round(STATE.precOpacity * 100) + '%';
- el('precLegend').hidden = mode === 'relief';
-}
-
-function renderBaseRow() {
- const b = STATE.base;
- const toggle = el('baseToggle');
- toggle.classList.toggle('on', b.on);
- toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF;
- el('baseOpacity').value = Math.round(b.opacity * 100);
- el('basePct').textContent = Math.round(b.opacity * 100) + '%';
- el('baseRow').classList.toggle('on', b.on);
-}
-
-// --- fiche dalle ----------------------------------------------------------
-let tileSel = null; // cadre de la dalle sélectionnée
-
-function esc(v) {
- return String(v == null ? '' : v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
-}
-
-function fmtDate(v, withTime) {
- if (!v) return '—';
- const d = new Date(v);
- if (isNaN(d)) return esc(v);
- const opt = withTime ? { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' }
- : { dateStyle: 'long', timeZone: 'Europe/Paris' };
- return d.toLocaleString('fr-FR', opt);
-}
-
-function kv(rows) {
- return '' + rows.filter(r => r[1] != null && r[1] !== '')
- .map(r => '' + r[0] + ' ' + r[1] + ' ').join('') + ' ';
-}
-
-function selectTileOutline(data) {
- if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
- 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,
- fill: false, dashArray: '6 4',
- interactive: false }).addTo(map);
-}
-
-function ignSection(ign) {
- if (!ign) return 'Chargement des informations IGN…
';
- if (ign.error) return 'Catalogue IGN injoignable pour le moment.
';
- if (!ign.found) return 'Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).
';
- const start = ign.acquisition_start, end = ign.acquisition_end;
- let when = fmtDate(start, true);
- if (start && end && String(start).slice(0, 10) === String(end).slice(0, 10) && String(end).length > 10) {
- when = fmtDate(start, true) + ' → ' + new Date(end).toLocaleTimeString('fr-FR',
- { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
- } else if (end && String(end).slice(0, 10) !== String(start || '').slice(0, 10)) {
- when = fmtDate(start) + ' → ' + fmtDate(end);
- }
- const pts = ign.points ? Number(ign.points).toLocaleString('fr-FR') + ' points' : null;
- return kv([
- ['Scan LiDAR', when],
- ['Capteur', (ign.sensors || []).map(esc).join(' ')],
- ['Mission', esc(ign.mission)],
- ['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('pour ' + ign.acquisition_owner)]
- .filter(Boolean).map(esc).join(' ')],
- ['Édition IGN', ign.edition_date ? fmtDate(ign.edition_date) : null],
- ['Classement', esc(ign.classification_process)],
- ['Nuage', pts],
- ]) + (ign.download_url
- ? '⤓ Télécharger le nuage LiDAR IGN (.copc.laz) '
- : '') + (ign.stac_url
- ? '' : '');
-}
-
-function calageSection(c) {
- if (!c || !c.beams || !c.beams.length) return '';
- const offs = Object.entries(c.offsets_cm || {}).map(([k, v]) => k + ' : ' + (v > 0 ? '+' : '') + String(v).replace('.', ',') + ' cm');
- const lines = Object.values(c.lines_rms_cm || {});
- return '
Recalage vertical des passes ' + kv([
- ['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
- ['Décalages', offs.length ? offs.join(' ') : 'aucun ≥ 0,5 cm'],
- ['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
- ]) + '';
-}
-
-function renderTileCard(data, ign) {
- const regenBtn = (GEN.available && GEN.allowed)
- ? ''
- : '';
- el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
- const bb = data.l93_bbox || [];
- let html = '' + esc(data.name) + '
' +
- '
Emprise ' + kv([
- ['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') +
- ' Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null],
- ['Surface', '1 km × 1 km'],
- ]) + '' +
- '
Acquisition LiDAR (IGN) ' + ignSection(ign) + '';
- if (data.found) {
- const viz = (data.layers || []).map(k => {
- const info = META.layers.find(l => l.key === k);
- return info ? info.label : k;
- }).join(', ');
- html += '
Rendu ' + kv([
- ['Résolution', String(data.resolution).replace('.', ',') + ' m/px'],
- ['Classification', esc(data.method || 'inconnue')],
- ['Généré le', esc(data.generated || '—')],
- ['Couches', esc(viz || '—')],
- ]) + '' + calageSection(data.calage);
- } else {
- html += 'Pas encore de rendu sur cette dalle.
';
- }
- el('tileBody').innerHTML = html + regenBtn;
- const btn = el('tileRegen');
- if (btn) btn.addEventListener('click', genAddCellFromQuery);
- el('tilecard').hidden = false;
-}
-
-function showTileInfo(data, latlng) {
- lastTileQuery = latlng || lastTileQuery;
- if (!data) {
- el('tileTitle').textContent = 'Dalle';
- el('tileBody').innerHTML = 'Serveur injoignable : réessayez dans un instant.
';
- el('tilecard').hidden = false;
- return;
- }
- selectTileOutline(data);
- renderTileCard(data, null);
- // Fiche IGN à part : la sélection s'affiche sans attendre le catalogue.
- const key = data.col + '_' + data.row;
- showTileInfo.current = key;
- fetch('api/map/ign?col=' + data.col + '&row=' + data.row)
- .then(r => r.ok ? r.json() : { error: 'HTTP ' + r.status })
- .catch(() => ({ error: 'réseau' }))
- .then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
-}
-
-// --- rose des vents du relief orienté --------------------------------------
-// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
-// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
-// chroma 60 · L*(100−L*)/2500, à clarté fixe.
-function labToRgb(L, a, b) {
- const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
- const finv = t => (t > 6 / 29 ? t * t * t : 3 * (6 / 29) * (6 / 29) * (t - 4 / 29));
- const X = 0.95047 * finv(fx), Y = finv(fy), Z = 1.08883 * finv(fz);
- const lin = [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z,
- 0.0557 * X - 0.2040 * Y + 1.0570 * Z];
- return lin.map(c => {
- const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;
- return Math.round(Math.min(1, Math.max(0, v)) * 255);
- });
-}
-
-function drawRose() {
- const cv = el('roseCanvas');
- if (!cv || drawRose.done) return;
- drawRose.done = true;
- const dpr = window.devicePixelRatio || 1, size = 132;
- cv.width = size * dpr; cv.height = size * dpr;
- const ctx = cv.getContext('2d');
- ctx.scale(dpr, dpr);
- const c = size / 2, rOut = 50, rIn = 22, L = 64;
- const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
- const img = ctx.createImageData(size * dpr, size * dpr);
- for (let py = 0; py < size * dpr; py++) {
- for (let px = 0; px < size * dpr; px++) {
- const x = px / dpr - c, y = py / dpr - c, r = Math.hypot(x, y);
- if (r > rOut || r < rIn) continue;
- const compass = (Math.atan2(x, -y) * 180 / Math.PI + 360) % 360; // 0 = N, sens horaire
- const h = ((compass + 90) % 360) * Math.PI / 180;
- const [R, G, B] = labToRgb(L, chroma * Math.cos(h), chroma * Math.sin(h));
- const i = (py * size * dpr + px) * 4;
- img.data[i] = R; img.data[i + 1] = G; img.data[i + 2] = B; img.data[i + 3] = 255;
- }
- }
- ctx.putImageData(img, 0, 0);
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.font = '600 11px system-ui, sans-serif';
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
- const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
- for (const [t, deg] of labels) {
- const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
- const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
- ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
- if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
- ctx.fillText(t, x, y);
- }
-}
-
-function updateRose() {
- const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
- && LAYERS.get('relief_oriente'));
- el('rose').hidden = !on;
- if (on) drawRose();
-}
-
-// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
-function renderUseCard() {
- const origin = location.origin;
- const opts = META.layers.map(l =>
- '' + l.label + ' ').join('');
- el('useBody').innerHTML =
- '' + opts + ' ' +
- 'Copier
' +
- 'URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' +
- '
' +
- '' +
- '' +
- '' +
- 'Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
';
- const sel = el('useLayer');
- const refresh = () => {
- const key = sel.value;
- el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png';
- const a = el('useJson');
- a.textContent = origin + '/tiles/' + key + '.json';
- a.href = origin + '/tiles/' + key + '.json';
- };
- sel.addEventListener('change', refresh);
- el('useCopy').addEventListener('click', () => {
- const text = el('useUrl').textContent;
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(() => toast('URL copiée'),
- () => window.prompt('URL XYZ :', text));
- } else { window.prompt('URL XYZ :', text); }
- });
- refresh();
- el('usecard').hidden = false;
-}
-
-// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
-// Portée de la webapp historique : dessin de zone (+), passe « tout
-// compléter » (⤒), sélection cumulée (clic fiche dalle), options de run,
-// file d'attente et progression dalle par dalle (cadres sur la carte).
-const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
-const genSel = new Set(); // clés "col,row" des dalles retenues
-const genCorners = new Map(); // clé → coins GPS [[lat, lon] × 4]
-const genPolys = new Map(); // clé → polygone de surlignage
-const genFrames = new Map(); // nom → cadre coloré de dalle en cours
-let genDrawing = false, genDrawStart = null, genDrawRect = null;
-let genIgnoreClickUntil = 0;
-let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
-
-for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670], ['tileSel', 680]]) {
- map.createPane(pane).style.zIndex = String(z);
-}
-
-function genKey(c, r) { return c + ',' + r; }
-
-function genApi(method, url, body) {
- return fetch(url, {
- method: method,
- headers: body ? { 'Content-Type': 'application/json' } : {},
- body: body ? JSON.stringify(body) : undefined,
- }).then(r => {
- if (!r.ok) {
- return r.json().catch(() => ({})).then(d => {
- throw new Error((d && d.detail) || ('HTTP ' + r.status));
- });
- }
- return r.json();
- });
-}
-
-function genOpenCard(msg) {
- if (msg !== undefined) el('genMsg').textContent = msg;
- el('gencard').hidden = false;
-}
-
-function genFillViz() {
- el('genViz').innerHTML = META.layers.map(l =>
- '' + l.label + ' ').join('');
-}
-
-function genSelectedViz() {
- return Array.from(el('genViz').selectedOptions).map(o => o.value);
-}
-
-function genRefreshSel() {
- for (const poly of genPolys.values()) poly.remove();
- genPolys.clear();
- for (const [key, corners] of genCorners.entries()) {
- if (!genSel.has(key) || !corners || corners.length < 4) continue;
- const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
- pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
- }).addTo(map);
- poly.on('click', (e) => {
- L.DomEvent.stopPropagation(e);
- genRemoveCell(key);
- toast('Dalle retirée de la sélection');
- });
- genPolys.set(key, poly);
- }
- el('genGo').disabled = !genSel.size;
- if (genSel.size) {
- el('genMsg').textContent = genSel.size + ' dalle(s) sélectionnée(s) — ' +
- 'cliquez un surlignage pour retirer une dalle, réglez les options puis « Générer ».';
- }
-}
-
-function genRemoveCell(key) {
- genSel.delete(key);
- genCorners.delete(key);
- genRefreshSel();
-}
-
-function genAddCells(cells) {
- let added = 0;
- for (const c of (cells || [])) {
- const k = genKey(c.col, c.row);
- if (!genSel.has(k)) { genSel.add(k); added++; }
- if (c.corners) genCorners.set(k, c.corners);
- }
- genRefreshSel();
- return added;
-}
-
-function genAddCellFromQuery() {
- if (!lastTileQuery) return;
- genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
- .then(d => {
- genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
- genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
- el('tilecard').hidden = true;
- })
- .catch(msg => toast('Échec : ' + msg.message, 6000));
-}
-
-function genSetDrawing(on) {
- genDrawing = on;
- document.body.classList.toggle('drawing', on);
- el('btnZone').classList.toggle('accent', on);
- if (on) map.dragging.disable(); else map.dragging.enable();
- if (!on && genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
- genDrawStart = null;
- if (on) genOpenCard('Dessinez un rectangle sur la carte (re-cliquez + pour annuler).');
-}
-
-map.on('mousedown', (e) => {
- if (!genDrawing) return;
- genDrawStart = e.latlng;
- if (genDrawRect) genDrawRect.remove();
- genDrawRect = L.rectangle([genDrawStart, genDrawStart], {
- pane: 'genDraw', color: '#4cc9f0', weight: 2, dashArray: '6 4',
- fillOpacity: 0.05, interactive: false,
- }).addTo(map);
-});
-map.on('mousemove', (e) => {
- if (!genDrawing || !genDrawStart || !genDrawRect) return;
- genDrawRect.setBounds(L.latLngBounds(genDrawStart, e.latlng));
-});
-map.on('mouseup', (e) => {
- if (!genDrawing || !genDrawStart) return;
- const b = L.latLngBounds(genDrawStart, e.latlng);
- genDrawStart = null;
- genIgnoreClickUntil = Date.now() + 400; // pas de fiche dalle après un dessin
- if (Math.abs(b.getWest() - b.getEast()) < 1e-9 ||
- Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
- genSetDrawing(false);
- if (genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
- genOpenCard('Analyse de la zone…');
- genApi('POST', 'api/preview', {
- bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
- regenerate: el('genRegen').checked,
- viz: genSelectedViz(),
- }).then(d => {
- const added = genAddCells(d.cells);
- genOpenCard((added ? added + ' nouvelle(s) dalle(s) retenue(s)'
- : 'zone déjà couverte — cochez « Régénérer » pour forcer') +
- (d.capped ? ' — zone plafonnée, resserrez le rectangle' : '') + '.');
- }).catch(msg => genOpenCard('Erreur : ' + msg.message));
-});
-
-function genComplete() {
- genOpenCard('Analyse des dalles déjà téléchargées…');
- genApi('POST', 'api/preview', {
- all_missing: true,
- regenerate: el('genRegen').checked,
- viz: genSelectedViz(),
- }).then(d => {
- const added = genAddCells(d.cells);
- genOpenCard((added || d.count || 0) + ' dalle(s) à compléter' +
- (d.capped ? ' — plafonnées à 10 000 par run' : '') + '.');
- if (genPolys.size) {
- const bounds = L.latLngBounds([]);
- genPolys.forEach(p => bounds.extend(p.getBounds()));
- map.fitBounds(bounds, { padding: [40, 40] });
- }
- }).catch(msg => genOpenCard('Erreur : ' + msg.message));
-}
-
-function genLaunch() {
- const tiles = [...genSel].map(k => k.split(',').map(Number));
- if (!tiles.length) return;
- genApi('POST', 'api/generate', {
- tiles: tiles,
- regenerate: el('genRegen').checked,
- viz: genSelectedViz(),
- }).then(d => {
- genSel.clear();
- genCorners.clear();
- genRefreshSel();
- GEN.wasRunning = true;
- genPollNow();
- toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)')
- : ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)'));
- }).catch(msg => toast('Échec : ' + msg.message, 6000));
-}
-
-// Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en
-// attente, rouge = échec) — redraw complet à chaque sondage, c'est peu.
-function genDrawFrames(tiles) {
- for (const poly of genFrames.values()) poly.remove();
- genFrames.clear();
- for (const t of (tiles || [])) {
- if (t.state === 'done' || !t.corners || t.corners.length < 4) continue;
- const cls = t.state === 'running' ? 'gen-frame-running'
- : t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending';
- const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), {
- pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15,
- interactive: false,
- }).addTo(map);
- genFrames.set(t.name || JSON.stringify(t.corners[0]), poly);
- }
-}
-
-// Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de
-// tuile neuf) et la maintenance de pyramide est relancée côté serveur.
-function genAfterRun() {
- fetch('api/map/meta').then(r => r.json()).then(meta => {
- META = meta;
- buildLayers();
- renderPanel();
- renderBaseRow();
- applyLayers();
- }).catch(() => {});
- fetch('api/map/background', { method: 'POST' }).catch(() => {});
-}
-
-function genUpdateStatus(d) {
- const queued = (d.queue || []).length;
- if (d.running || queued) {
- el('genActions').hidden = true;
- el('genOpts').hidden = true;
- el('genRun').hidden = false;
- const tiles = d.tiles || [];
- const done = tiles.filter(t => t.state === 'done').length;
- const failed = tiles.filter(t => t.state === 'failed').length;
- let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)')
- : 'Démarrage du run suivant…';
- if (failed) txt += ' • ' + failed + ' échec(s)';
- if (queued) txt += ' • ' + queued + ' demande(s) en file';
- el('genProgress').textContent = txt;
- el('genLog').textContent = (d.log || []).slice(-12).join('\n');
- genDrawFrames(tiles);
- GEN.wasRunning = true;
- } else {
- el('genRun').hidden = true;
- el('genActions').hidden = !GEN.available;
- el('genOpts').hidden = false;
- genDrawFrames(null);
- if (GEN.wasRunning) {
- GEN.wasRunning = false;
- toast(d.returncode === 0 ? 'Génération terminée'
- : 'Génération interrompue (code ' + d.returncode + ')', 6000);
- genAfterRun();
- }
- }
- el('genBadge').textContent = d.running ? '● en cours'
- : queued ? ('⏱ ' + queued) : '';
- el('btnZone').classList.toggle('busy', !!d.running || !!queued);
-}
-
-function genPollNow() {
- if (!GEN.available) return;
- fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
- if (d) genUpdateStatus(d);
- genPollSoon();
- }).catch(() => genPollSoon());
-}
-
-function genPollSoon() {
- clearTimeout(GEN.timer);
- GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000);
-}
-
-el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
-el('btnComplete').addEventListener('click', genComplete);
-el('genClose').addEventListener('click', () => {
- el('gencard').hidden = true;
- if (genDrawing) genSetDrawing(false);
-});
-el('genClear').addEventListener('click', () => {
- genSel.clear();
- genCorners.clear();
- genRefreshSel();
- genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).');
-});
-el('genGo').addEventListener('click', genLaunch);
-el('genStop').addEventListener('click', () =>
- genApi('POST', 'api/stop')
- .then(() => toast('Arrêt demandé'))
- .catch(msg => toast('Échec : ' + msg.message, 6000)));
-el('genQueueClear').addEventListener('click', () =>
- genApi('POST', 'api/queue/clear')
- .then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
- .catch(msg => toast('Échec : ' + msg.message, 6000)));
-
-// --- export PDF (planche d'impression terrain) ---
-// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte
-// se déplace librement autour, on le fait glisser à la souris ou au doigt.
-// Géométrie exacte calculée par le serveur (projection Lambert 93).
-const PRINT_LS = 'lidar-print';
-const PRINT = { on: false, rect: null, handle: null, seq: 0, center: null, corners: null, drag: null };
-map.createPane('printFrame').style.zIndex = '690';
-
-function printSettings() {
- return { paper: el('printPaper').value, orient: el('printOrient').value,
- scale: el('printScale').value, title: el('printTitle').value.trim() };
-}
-
-function printQuery(s, withTitle) {
- const c = PRINT.center || map.getCenter();
- const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6),
- paper: s.paper, orient: s.orient, scale: s.scale });
- if (withTitle && s.title) q.set('title', s.title);
- return q.toString();
-}
-
-function printSave() {
- const s = printSettings();
- if (PRINT.center) s.center = [PRINT.center.lat, PRINT.center.lng];
- try { localStorage.setItem(PRINT_LS, JSON.stringify(s)); } catch (e) { /* privé */ }
-}
-
-function printLoad() {
- try {
- const s = JSON.parse(localStorage.getItem(PRINT_LS) || '{}');
- for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'],
- ['scale', 'printScale'], ['title', 'printTitle']]) {
- if (s[k] != null) el(id).value = s[k];
- }
- if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center);
- } catch (e) { /* privé ou illisible : réglages par défaut */ }
-}
-
-// Recadre sur le cadre en laissant libre la bande basse couverte par les
-// cartes (couches, export…) : sinon le bas du cadre passe dessous.
-function printFit() {
- if (!PRINT.corners) return;
- const box = map.getContainer().getBoundingClientRect();
- let bottom = 0;
- for (const c of document.querySelectorAll('.card')) {
- if (c.hidden || !c.offsetParent) continue;
- const r = c.getBoundingClientRect();
- if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top);
- }
- map.fitBounds(L.latLngBounds(PRINT.corners),
- { paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] });
-}
-
-function printDraw(corners) {
- PRINT.corners = corners;
- if (PRINT.rect) PRINT.rect.setLatLngs(corners);
- else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: '#ffd166', weight: 2,
- dashArray: '8 5', fill: false, interactive: false }).addTo(map);
- if (PRINT.handle) { PRINT.handle.setLatLng(PRINT.center); return; }
- PRINT.handle = L.marker(PRINT.center, {
- pane: 'printFrame', draggable: true, autoPan: true, keyboard: false,
- title: 'Faire glisser pour déplacer la zone imprimée',
- icon: L.divIcon({ className: 'print-handle', html: '✥', iconSize: [34, 34] }),
- }).addTo(map);
- PRINT.handle.on('dragstart', printDragStart);
- PRINT.handle.on('drag', printDragMove);
- PRINT.handle.on('dragend', printDragEnd);
-}
-
-// fit : recadrer la vue sur le cadre une fois la géométrie reçue.
-function printRefreshFrame(fit) {
- if (!PRINT.on) return;
- const seq = ++PRINT.seq;
- fetch('api/export/frame?' + printQuery(printSettings(), false))
- .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
- .then(d => {
- if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return;
- printDraw(d.corners);
- el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m';
- if (fit === true) printFit();
- })
- .catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; });
-}
-
-// Glisser la poignée : le cadre suit en direct (translation en pixels de calque,
-// stables pendant le défilement automatique),
-// géométrie exacte redemandée au serveur au relâchement.
-function printDragStart() {
- PRINT.drag = { start: map.latLngToLayerPoint(PRINT.handle.getLatLng()),
- corners: PRINT.corners.map(c => map.latLngToLayerPoint(c)) };
-}
-
-function printDragMove() {
- const d = PRINT.drag;
- if (!d) return;
- const delta = map.latLngToLayerPoint(PRINT.handle.getLatLng()).subtract(d.start);
- PRINT.rect.setLatLngs(d.corners.map(p => map.layerPointToLatLng(p.add(delta))));
-}
-
-function printDragEnd() {
- PRINT.drag = null;
- PRINT.center = PRINT.handle.getLatLng();
- PRINT.corners = PRINT.rect.getLatLngs()[0];
- printSave();
- printRefreshFrame();
-}
-
-function printCenterHere() {
- PRINT.center = map.getCenter();
- printSave();
- printRefreshFrame();
-}
-
-function printSetOpen(on) {
- PRINT.on = on;
- el('printcard').hidden = !on;
- el('btnPrint').classList.toggle('accent', on);
- if (on) {
- // 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();
- printRefreshFrame(true);
- } else {
- if (PRINT.rect) PRINT.rect.remove();
- if (PRINT.handle) PRINT.handle.remove();
- PRINT.rect = PRINT.handle = PRINT.corners = null;
- }
-}
-
-function printExport() {
- const btn = el('printGo');
- btn.disabled = true;
- el('printMsg').textContent = 'Génération du PDF…';
- fetch('api/export/pdf?' + printQuery(printSettings(), true))
- .then(r => {
- if (!r.ok) {
- return r.json().catch(() => ({})).then(d => {
- throw new Error((d && d.detail) || ('HTTP ' + r.status));
- });
- }
- const m = /filename="([^"]+)"/.exec(r.headers.get('Content-Disposition') || '');
- return r.blob().then(b => ({ b: b, name: m ? m[1] : 'relief.pdf' }));
- })
- .then(({ b, name }) => {
- const a = document.createElement('a');
- a.href = URL.createObjectURL(b);
- a.download = name;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(a.href), 10000);
- el('printMsg').textContent = 'Téléchargé : ' + name;
- })
- .catch(e => { el('printMsg').textContent = 'Échec : ' + e.message; })
- .finally(() => { btn.disabled = false; });
-}
-
-el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
-el('printClose').addEventListener('click', () => printSetOpen(false));
-el('printGo').addEventListener('click', printExport);
-el('printHere').addEventListener('click', printCenterHere);
-el('printFit').addEventListener('click', printFit);
-for (const id of ['printPaper', 'printOrient', 'printScale']) {
- el(id).addEventListener('change', () => { printSave(); printRefreshFrame(true); });
-}
-el('printTitle').addEventListener('change', printSave);
-printLoad();
-
-// --- démarrage ------------------------------------------------------------
-function fitData() {
- if (META.bounds) {
- map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]],
- { padding: [30, 30] });
- } else {
- map.setView([46.6, 2.5], 6);
- }
-}
-
-fetch('api/map/meta').then(r => r.json()).then(meta => {
- META = meta;
- if (!META.layers.length) {
- toast('Aucune visualisation disponible dans le cache.', 10000);
- }
- STATE = loadState(META);
- // L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
- // suite : ce qui est à l'écran est ce qui sera rechargé.
- saveState();
- el('stat').textContent = META.layers.length + ' couche(s)';
- const hash = parseHash();
- if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
- else fitData();
- buildLayers();
- renderPanel();
- renderBaseRow();
- genFillViz();
- // Droits de génération : boutons affichés si le serveur peut générer
- // (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
- // réseau autorisé (LIDAR_REGEN_CIDR) — sinon masqués silencieusement.
- fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
- if (!d) return;
- GEN.available = !!d.available;
- GEN.allowed = d.regen_allowed !== false;
- const show = GEN.available && GEN.allowed;
- el('btnZone').hidden = !show;
- el('btnComplete').hidden = !show;
- if (show) { genUpdateStatus(d); genPollNow(); }
- }).catch(() => {});
-}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
-
-let applyingHash = false;
-window.addEventListener('hashchange', () => {
- // Lien partagé collé dans l'onglet courant : la vue (et l'état des couches
- // s'il est présent) est appliquée sans rechargement.
- if (applyingHash || !META) return;
- const h = parseHash();
- if (!h) return;
- applyingHash = true;
- map.setView([h.lat, h.lng], h.zoom);
- if (h.main || h.view || h.base) {
- STATE = loadState(META);
- saveState(); renderPanel(); renderBaseRow(); applyLayers();
- }
- setTimeout(() => { applyingHash = false; }, 0);
-});
-
-map.on('moveend zoomend', saveHashSoon);
-map.on('click', (e) => {
- if (genDrawing || Date.now() < genIgnoreClickUntil) return;
- fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
- .then(r => r.ok ? r.json() : null)
- .catch(() => null)
- .then(d => showTileInfo(d, e.latlng));
-});
-
-el('cardToggle').addEventListener('click', () =>
- el('layercard').classList.toggle('collapsed'));
-el('tileClose').addEventListener('click', () => {
- el('tilecard').hidden = true;
- if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
-});
-el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
-el('btnUse').addEventListener('click', renderUseCard);
-el('btnShare').addEventListener('click', () => {
- const url = location.origin + location.pathname + viewHash(true);
- history.replaceState(null, '', url);
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(url).then(() => toast('Lien copié'),
- () => window.prompt('Lien :', url));
- } else { window.prompt('Lien :', url); }
-});
-el('btnDefault').addEventListener('click', () => {
- const body = {
- main: STATE.main,
- mode: STATE.mode,
- precision_opacity: STATE.precOpacity,
- base: STATE.base,
- };
- fetch('api/map/defaults', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- }).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
- .then(() => {
- META.default_main = STATE.main; META.default_mode = STATE.mode;
- META.default_precision_opacity = STATE.precOpacity; META.default_base = STATE.base;
- toast('Affichage enregistré comme défaut');
- })
- .catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
-});
-
-el('modeSeg').querySelectorAll('button').forEach(b =>
- b.addEventListener('click', () => setMode(b.dataset.mode)));
-el('mainSel').addEventListener('change', (e) => {
- STATE.main = e.target.value;
- saveState(); renderPanel(); applyLayers();
-});
-el('precOpacity').addEventListener('input', (e) => {
- STATE.precOpacity = Number(e.target.value) / 100;
- el('precPct').textContent = e.target.value + '%';
- const layer = LAYERS.get(precisionKey());
- if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.precOpacity);
-});
-el('precOpacity').addEventListener('change', saveState);
-
-el('btnReset').addEventListener('click', () => {
- // Oublie l'état local et reprend la configuration servie par le serveur.
- try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
- fetch('api/map/meta').then(r => r.json()).then(meta => {
- META = meta;
- STATE = defaultState(META);
- saveState(); renderPanel(); renderBaseRow(); applyLayers();
- toast('Configuration par défaut rétablie');
- }).catch(() => toast('Serveur injoignable', 6000));
-});
-
-el('baseToggle').addEventListener('click', () => {
- STATE.base.on = !STATE.base.on;
- saveState(); renderBaseRow(); applyLayers();
-});
-el('baseName').addEventListener('click', () => {
- STATE.base.dark = !STATE.base.dark;
- saveState(); applyLayers();
-});
-el('baseOpacity').addEventListener('input', (e) => {
- STATE.base.opacity = Number(e.target.value) / 100;
- el('basePct').textContent = e.target.value + '%';
- base.setOpacity(STATE.base.opacity);
-});
-el('baseOpacity').addEventListener('change', saveState);
-el('zoomIn').addEventListener('click', () => map.zoomIn(1));
-el('zoomOut').addEventListener('click', () => map.zoomOut(1));
-el('zoomFit').addEventListener('click', fitData);
-el('gps').addEventListener('click', () => {
- if (!('geolocation' in navigator)) {
- toast('Géolocalisation indisponible (HTTPS requis).', 6000);
- return;
- }
- const btn = el('gps');
- btn.classList.add('busy');
- navigator.geolocation.getCurrentPosition((pos) => {
- btn.classList.remove('busy');
- const ll = [pos.coords.latitude, pos.coords.longitude];
- map.setView(ll, Math.max(map.getZoom(), 16));
- if (window._gpsMarker) window._gpsMarker.remove();
- window._gpsMarker = L.circleMarker(ll, {
- radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9
- }).addTo(map);
- }, (err) => {
- btn.classList.remove('busy');
- const why = err && err.code === 1 ? 'Autorisation refusée'
- : err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible';
- toast(why + ' (la géolocalisation exige HTTPS).', 6000);
- }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
-});
-document.addEventListener('keydown', (e) => {
- // P : relief → précision → les deux → relief (hors saisie de texte)
- const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
- if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
- && META && STATE && precisionKey()) {
- const i = VIEW_KEYS.indexOf(STATE.mode);
- setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
- return;
- }
- if (e.key === 'Escape') {
- el('tilecard').hidden = true; el('usecard').hidden = true;
- if (genDrawing) genSetDrawing(false);
- }
-});
-"""
diff --git a/lidar_pipeline/tests/test_mapui.py b/lidar_pipeline/tests/test_mapui.py
new file mode 100644
index 0000000..1dd2b00
--- /dev/null
+++ b/lidar_pipeline/tests/test_mapui.py
@@ -0,0 +1,37 @@
+"""Chargeur de l'interface : fichiers web/ du paquet, versions, syntaxe JS."""
+import shutil
+import subprocess
+
+import pytest
+
+
+def test_constants_are_read_from_web_files():
+ from lidar_pipeline import mapui
+ for name, text in (("map.html", mapui._MAP_HTML), ("map.css", mapui._MAP_CSS),
+ ("map.js", mapui._MAP_JS)):
+ assert (mapui.WEB_DIR / name).read_text(encoding="utf-8") == text
+
+
+def test_web_files_ship_with_installed_package():
+ # Image complète : paquet installé par pip (package_data) — les trois
+ # fichiers doivent y être, sinon l'interface est vide en production.
+ import lidar_pipeline
+ from pathlib import Path
+ web = Path(lidar_pipeline.__file__).resolve().parent / "web"
+ for name in ("map.html", "map.css", "map.js"):
+ assert (web / name).is_file(), name
+
+
+def test_markers_kept():
+ from lidar_pipeline import mapui
+ assert "__ASSETS_V__" in mapui._MAP_HTML
+ assert "__UI_VERSION__" in mapui._MAP_JS
+ assert "__ASSETS_V__" not in mapui.render_html()
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node absent")
+def test_js_syntax():
+ from lidar_pipeline import mapui
+ res = subprocess.run(["node", "--check", str(mapui.WEB_DIR / "map.js")],
+ capture_output=True, text=True)
+ assert res.returncode == 0, res.stderr
diff --git a/lidar_pipeline/web/map.css b/lidar_pipeline/web/map.css
new file mode 100644
index 0000000..18fdbb7
--- /dev/null
+++ b/lidar_pipeline/web/map.css
@@ -0,0 +1,198 @@
+/* Carte LiDAR — tuiles XYZ, habillage minimal type carte grand public. */
+:root {
+ color-scheme: dark;
+ --bg: #0b0e13;
+ --card: rgba(16, 20, 27, 0.94);
+ --border: rgba(255, 255, 255, 0.10);
+ --text: #d9dee6;
+ --muted: #8b94a3;
+ --accent: #5aa9e6;
+ --radius: 12px;
+}
+* { box-sizing: border-box; margin: 0; padding: 0; }
+[hidden] { display: none !important; }
+html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
+ font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+#map { position: absolute; inset: 0; background: var(--bg); }
+.leaflet-container { background: var(--bg); font: inherit; }
+/* Fond OSM assombri pour laisser ressortir les rendus LiDAR. Filtre posé sur
+ le conteneur de la couche, pas sur chaque tuile : même rendu (opérations
+ par pixel), mais une seule passe de filtre au lieu d'une par tuile —
+ −30 % de CPU (moteur de rendu + GPU) en déplacement, mesuré sous Chrome. */
+.leaflet-tile-pane .leaflet-layer.base-dark { filter: brightness(0.38) contrast(1.1) saturate(0.7); }
+
+.card { position: fixed; z-index: 900; background: var(--card); color: var(--text);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
+#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
+#tilecard { left: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px);
+ max-height: calc(100vh - 24px); overflow-y: auto; }
+.info-sec { margin-top: 10px; }
+.info-sec h4 { margin: 0 0 4px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;
+ text-transform: uppercase; color: var(--muted); }
+.info-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; font-size: 12.5px; }
+.info-kv dt { color: var(--muted); }
+.info-kv dd { margin: 0; overflow-wrap: anywhere; }
+.info-dl { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: 6px;
+ background: rgba(90, 160, 255, 0.18); color: var(--text); text-decoration: none; font-weight: 600; }
+.info-dl:hover { background: rgba(90, 160, 255, 0.3); }
+#rose { right: 12px; bottom: 190px; padding: 8px 8px 6px; display: flex; flex-direction: column;
+ align-items: center; gap: 2px; z-index: 800; }
+#rose canvas { width: 132px; height: 132px; }
+.rose-cap { font-size: 11px; text-align: center; color: var(--text); line-height: 1.3; }
+.rose-cap span { color: var(--muted); font-size: 10.5px; }
+#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: 910; }
+.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
+.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
+.card-stat { margin-left: auto; font-size: 11px; color: var(--muted); }
+.card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; }
+#layercard.collapsed .card-body { display: none; }
+#layercard.collapsed #cardToggle { transform: rotate(-90deg); }
+.card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); }
+
+button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
+ border-radius: 8px; padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
+button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
+.icon-btn { border: none; background: none; color: var(--muted); padding: 2px 6px; font-size: 14px; }
+.icon-btn:hover { color: var(--text); background: none; }
+.link-btn { flex: 1; font-size: 11.5px; }
+
+/* Ligne du fond de carte : œil, nom, opacité. */
+.layer-row { display: grid; grid-template-columns: 14px 24px minmax(0, 1fr) auto 34px;
+ align-items: center; gap: 6px; padding: 5px 4px; border-radius: 8px; user-select: none; }
+.layer-ctl { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 84px;
+ gap: 6px; align-items: center; padding: 2px 0 2px 38px; }
+.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
+/* Affichage : une couche principale, la précision seule ou superposée. */
+.view-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center;
+ gap: 8px; padding: 8px 4px 4px; border-top: 1px solid var(--border); margin-top: 4px; }
+.view-label { font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
+.view-row .layer-name { color: var(--text); }
+.view-sel { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
+ border-radius: 6px; padding: 2px 4px; font: inherit; font-size: 12px; min-width: 0; width: 100%; height: 24px; }
+.view-sel option { background-color: #10141b; color: var(--text); }
+.mode-seg { display: grid; grid-template-columns: repeat(3, 1fr); margin: 6px 4px 2px;
+ border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
+.mode-seg button { border: none; border-radius: 0; background: rgba(255, 255, 255, 0.04);
+ color: var(--muted); padding: 6px 4px; font-size: 12px; }
+.mode-seg button + button { border-left: 1px solid var(--border); }
+.mode-seg button:hover { color: var(--text); background: rgba(255, 255, 255, 0.1); }
+.mode-seg button[aria-pressed="true"] { background: var(--accent); color: #0b0e13; font-weight: 600; }
+.mode-seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.prec-ctl { display: grid; grid-template-columns: auto minmax(0, 1fr) 34px; gap: 8px;
+ align-items: center; padding: 6px 4px 0; font-size: 11.5px; color: var(--muted); }
+.prec-legend { padding: 8px 4px 2px; }
+.prec-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px;
+ border: 1px solid var(--border); border-radius: 3px; overflow: hidden; }
+.prec-ticks { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--muted);
+ font-variant-numeric: tabular-nums; margin-top: 2px; }
+.prec-cap { font-size: 10.5px; color: var(--muted); margin-top: 2px; }
+.grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; }
+.grip::before { content: '⠿'; }
+#baseRow .grip::before { content: ''; }
+.eye { width: 24px; height: 24px; padding: 0; border: none; background: none; color: var(--muted);
+ display: flex; align-items: center; justify-content: center; }
+.eye.on { color: var(--accent); }
+.eye svg { width: 16px; height: 16px; }
+.layer-name { font-size: 12.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.layer-row.on .layer-name { color: var(--text); }
+.pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
+input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
+ background: transparent; cursor: pointer; }
+input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
+input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%;
+ background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; }
+input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
+input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; }
+
+#tools { position: fixed; right: 12px; bottom: 12px; z-index: 900; display: flex; flex-direction: column; gap: 6px; }
+#tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card);
+ border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
+#gps.busy { color: var(--accent); border-color: var(--accent); }
+/* Indicateur de chargement : tuiles restantes pour finir la vue courante
+ (fond + LiDAR), barre de progression du lot en cours. */
+.tileload { position: fixed; left: 50%; top: 12px; transform: translateX(-50%); z-index: 905;
+ display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: 999px;
+ background: var(--card); border: 1px solid var(--border); font-size: 12px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); pointer-events: none;
+ transition: opacity 0.3s ease; white-space: nowrap; }
+.tileload.done { opacity: 0; }
+.tl-spin { width: 12px; height: 12px; border-radius: 50%; flex: none;
+ border: 2px solid rgba(90, 169, 230, 0.25); border-top-color: var(--accent);
+ animation: tlspin 0.8s linear infinite; }
+.tileload.done .tl-spin { animation: none; border-color: var(--accent); }
+@keyframes tlspin { to { transform: rotate(360deg); } }
+.tl-bar { width: 60px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.1);
+ overflow: hidden; flex: none; }
+.tl-bar span { display: block; height: 100%; width: 0; background: var(--accent);
+ transition: width 0.2s ease; }
+.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
+ background: var(--card); border: 1px solid var(--border); border-radius: 8px;
+ padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; }
+
+.meta { font-size: 11.5px; color: var(--muted); margin: 2px 0; }
+.meta b { color: var(--text); font-weight: 600; }
+.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
+ background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 6px;
+ padding: 6px 8px; word-break: break-all; margin: 4px 0 8px; }
+.use-row { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; }
+.use-row select { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
+ border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 12px; }
+.use-note { font-size: 11px; color: var(--muted); line-height: 1.5; }
+.leaflet-control-attribution { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; }
+.leaflet-control-attribution a { color: var(--accent) !important; }
+
+/* --- génération de tuiles ------------------------------------------------ */
+#gencard { right: 12px; top: 56px; width: 340px; max-width: calc(100vw - 24px); }
+#printcard { right: 60px; bottom: 12px; width: 340px; max-width: calc(100vw - 84px); }
+#printTitle { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
+ border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; }
+.print-hint { margin: 4px 0; font-size: 12px; opacity: 0.75; }
+.print-msg { font-size: 12px; margin-left: 8px; }
+.print-handle { display: flex; align-items: center; justify-content: center; cursor: move;
+ font-size: 20px; color: #1b1b1b; background: #ffd166; border: 2px solid #1b1b1b;
+ border-radius: 50%; box-shadow: 0 1px 6px rgba(0,0,0,0.5); }
+body.drawing { cursor: crosshair !important; }
+body.drawing .leaflet-container { cursor: crosshair !important; }
+/* Barre de génération : en HAUT à droite de l'écran, boutons accentués —
+ c'est l'entrée principale du traitement, elle ne se confond pas avec les
+ icônes de navigation (zoom/GPS) du coin bas droit. */
+#gentools { position: fixed; top: 12px; right: 12px; z-index: 900; display: flex; gap: 8px; }
+#gentools button { background: var(--card); color: var(--accent); border: 1px solid var(--accent);
+ border-radius: 10px; padding: 8px 14px; font: inherit; font-size: 13px; font-weight: 600;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
+#gentools button:hover { background: var(--accent); color: #0b0e13; }
+#gentools button.accent { background: var(--accent); color: #0b0e13; }
+.gen-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px;
+ align-items: center; margin: 4px 0; font-size: 11.5px; color: var(--muted); }
+#genViz { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border);
+ border-radius: 6px; padding: 3px 4px; font: inherit; font-size: 11.5px; }
+#genViz option { background-color: #10141b; }
+#genClass, .gen-input { background: rgba(255,255,255,0.06); color: var(--text);
+ border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px;
+ font: inherit; font-size: 11.5px; width: 100%; height: 24px; }
+.chk { display: flex; gap: 7px; align-items: center; font-size: 11.5px;
+ color: var(--muted); margin: 4px 0; cursor: pointer; }
+.chk input { accent-color: var(--accent); }
+button.primary { background: var(--accent); border-color: var(--accent); color: #0b0e13; font-weight: 600; }
+button.primary:hover { filter: brightness(1.12); }
+.gen-log { font: 10.5px/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: 6px;
+ padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap;
+ word-break: break-all; margin: 6px 0; }
+#tools button.accent { color: var(--accent); border-color: var(--accent); }
+#tools button.busy { color: var(--accent); border-color: var(--accent); }
+/* Cadres des dalles du run : orange pulsant = rendu en cours, bleu = en
+ attente, rouge = échec. Les dalles faites n'ont pas de cadre. */
+@keyframes genpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
+.gen-frame-running { stroke: #f4a261; fill: #f4a26122; animation: genpulse 1.4s ease-in-out infinite; }
+.gen-frame-pending { stroke: #4cc9f0; fill: #4cc9f014; stroke-dasharray: 6 4; }
+.gen-frame-failed { stroke: #e76f51; fill: #e76f5133; }
+.gen-sel { stroke: var(--accent); fill: #5aa9e61f; cursor: pointer; }
+@media (max-width: 720px) {
+ #layercard { width: calc(100vw - 24px); }
+ #tilecard { width: calc(100vw - 24px); }
+ #gencard { width: calc(100vw - 24px); }
+ #printcard { width: calc(100vw - 84px); }
+}
diff --git a/lidar_pipeline/web/map.html b/lidar_pipeline/web/map.html
new file mode 100644
index 0000000..c7b99bc
--- /dev/null
+++ b/lidar_pipeline/web/map.html
@@ -0,0 +1,160 @@
+
+
+
+
+
+Carte LiDAR
+
+
+
+
+
+
+
+
+
+ ▾
+ Couches
+
+
+
+
+
+
+
Fond OpenStreetMap
+
+
+
+
+
+
+ Affichage
+
+
+
+
+
+ Relief
+ Précision
+ Les deux
+
+
+ Opacité de la précision
+
+
+
+
+
+
≤ 0,35 4 ≥ 45 pts/m²
+
Points sol par m² : sombre = relief interpolé, noir = aucun point
+
+
+
+
+
+
+
+
+
+
+
+
Pente orientée vers…clair = bosse · sombre = creux
+
+
+
+
+ Fond d'imagerie XYZ
+ ×
+
+
+
+
+
+
+ Export PDF
+
+ ×
+
+
+
+ A4 A3
+
+ Paysage Portrait
+
+ 1:1 000 1:2 000
+ 1:5 000 1:10 000
+
+
Faites glisser la poignée ✥ pour placer le cadre jaune sur la zone à imprimer.
+
⌖ Centrer ici
+ ⤢ Voir le cadre
+
+
+
+
+
+
+ Génération
+
+ ×
+
+
+
Dessinez une zone (+) ou complétez les dalles existantes (⤒).
+
+
Couches
+
+
Régénérer les tuiles existantes
+
Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)
+
+
+
+
+
+
+
+
+ + Zone
+ ⤒ Compléter
+
+
+ +
+ −
+ ⤢
+ ⌖
+ ⎙
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lidar_pipeline/web/map.js b/lidar_pipeline/web/map.js
new file mode 100644
index 0000000..6aac911
--- /dev/null
+++ b/lidar_pipeline/web/map.js
@@ -0,0 +1,1237 @@
+'use strict';
+/* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation.
+ Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier
+ d'image, aucune rotation CSS, aucune mosaïque maison. */
+
+const UI_VERSION = "__UI_VERSION__";
+const LS_KEY = 'lidarMapView_v2';
+const el = (id) => document.getElementById(id);
+const SVG_EYE = ' ';
+const SVG_EYE_OFF = ' ';
+
+// Affichage : une couche principale (relief orienté) et la couche
+// « précision » (densité de points sol), montrée seule ou superposée en
+// « produit » (mix-blend-mode multiply) sur la principale : les zones peu
+// fiables s'assombrissent sans masquer le relief. Touche P : mode suivant.
+const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['both', 'Les deux']];
+const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
+const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
+const clamp01 = (v, d) => {
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : d;
+};
+// Paliers de la précision : niveau k à partir de 0,25 × 2^(k/2) pts/m²
+// (visualizations.density_levels), gris = colormap « gray » sur 0..15.
+const PREC_LEVELS = 16;
+
+let META = null;
+let STATE = null;
+const LAYERS = new Map(); // clé → L.TileLayer
+
+const map = L.map('map', {
+ zoomControl: false,
+ attributionControl: true,
+ minZoom: 3,
+ // Zoom plafonné au natif (19 = 0,2 m/px, 1 px écran = 1 px LiDAR) : toute
+ // la pyramide est générée d'avance, jamais de tuile agrandie ni étirée.
+ maxZoom: 19,
+ zoomSnap: 0.5,
+ wheelPxPerZoomLevel: 90,
+ worldCopyJump: false,
+ preferCanvas: false
+});
+map.attributionControl.setPrefix('');
+L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
+
+// Pendant l'animation de zoom, transformations SEULES (aucun niveau créé,
+// tuiles chargées à la fin) : un niveau neuf n'est pas composé par Chrome
+// (vide au départ, il est animé sur le thread principal, gelé ~90 ms par le
+// chargement). Le fond sautait alors que le relief orienté, qui garde son
+// niveau pair, glissait normalement — les deux couches se désynchronisaient.
+// Leaflet 1.9 déclenche aussi « zoom » dès le DÉBUT de l'animation : même
+// garde dans _resetView tant que la carte anime.
+const ZoomAnimTileLayer = L.TileLayer.extend({
+ _animateZoom(e) { this._setView(e.center, e.zoom, true, true); },
+ _resetView(e) {
+ if (this._map && this._map._animatingZoom) {
+ this._setView(this._map.getCenter(), this._map.getZoom(), true, true);
+ return;
+ }
+ L.TileLayer.prototype._resetView.call(this, e);
+ }
+});
+
+// --- chargement des tuiles -------------------------------------------------
+// Compteur recalculé depuis l'état réel des tuiles de chaque couche (tuiles
+// du niveau courant pas encore chargées) plutôt que par +1/−1 sur les
+// événements : une tuile retirée avant la fin de son chargement (déplacement
+// rapide) ne déclenche ni « tileload » ni « tileerror », un compteur par
+// événements dériverait. Lot : de la première tuile en attente au retour à
+// zéro ; la barre donne la part déjà chargée du lot.
+const TILE_LOAD = { layers: new Set(), batch: 0, done: 0, raf: 0, hideT: 0 };
+
+function tilesPending() {
+ let n = 0;
+ for (const layer of TILE_LOAD.layers) {
+ if (!layer._map) continue;
+ for (const key in layer._tiles) {
+ const t = layer._tiles[key];
+ if (t.current && !t.loaded) n += 1;
+ }
+ }
+ return n;
+}
+
+function updateTileLoad() {
+ TILE_LOAD.raf = 0;
+ const box = el('tileload');
+ if (!box) return;
+ const pending = tilesPending();
+ if (pending > 0) {
+ clearTimeout(TILE_LOAD.hideT);
+ TILE_LOAD.hideT = 0;
+ if (TILE_LOAD.batch === 0) TILE_LOAD.done = 0;
+ TILE_LOAD.batch = Math.max(TILE_LOAD.batch, TILE_LOAD.done + pending);
+ el('tileloadText').textContent = pending + (pending > 1 ? ' tuiles restantes' : ' tuile restante');
+ el('tileloadBar').style.width = Math.round(100 * TILE_LOAD.done / TILE_LOAD.batch) + '%';
+ box.classList.remove('done');
+ box.hidden = false;
+ } else if (TILE_LOAD.batch > 0 && !TILE_LOAD.hideT) {
+ TILE_LOAD.batch = 0;
+ el('tileloadText').textContent = 'Vue chargée';
+ el('tileloadBar').style.width = '100%';
+ box.classList.add('done');
+ TILE_LOAD.hideT = setTimeout(() => { box.hidden = true; TILE_LOAD.hideT = 0; }, 600);
+ }
+}
+
+function scheduleTileLoad() {
+ if (!TILE_LOAD.raf) TILE_LOAD.raf = requestAnimationFrame(updateTileLoad);
+}
+
+ZoomAnimTileLayer.addInitHook(function () {
+ TILE_LOAD.layers.add(this);
+ const onDone = () => { TILE_LOAD.done += 1; scheduleTileLoad(); };
+ this.on('tileload', onDone);
+ this.on('tileerror', onDone);
+ this.on('tileloadstart tileunload load add remove', scheduleTileLoad);
+});
+
+const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
+ // Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
+ updateWhenZooming: false,
+ attribution: '© OpenStreetMap '
+}).addTo(map);
+
+function toast(msg, ms) {
+ const t = el('toast');
+ t.textContent = msg;
+ t.hidden = false;
+ clearTimeout(toast._t);
+ toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500);
+}
+
+// --- état persisté --------------------------------------------------------
+function precisionKey() {
+ const p = META && META.precision_layer;
+ return p && META.layers.some(l => l.key === p) ? p : null;
+}
+
+function mainKeys(meta) {
+ return meta.layers.map(l => l.key).filter(k => k !== meta.precision_layer);
+}
+
+function validMain(meta, k) {
+ return mainKeys(meta).includes(k) ? k : null;
+}
+
+function defaultState(meta) {
+ const b = meta.default_base || {};
+ return {
+ main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
+ mode: validMode(meta.default_mode) || 'relief',
+ 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 : true },
+ };
+}
+
+function loadState(meta) {
+ const def = defaultState(meta);
+ let st = def;
+ try {
+ const raw = localStorage.getItem(LS_KEY);
+ if (raw) {
+ const s = JSON.parse(raw);
+ 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 || {}) };
+ }
+ } catch (e) { st = def; }
+ // 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).
+ const hash = parseHash();
+ if (hash) {
+ if (hash.main && validMain(meta, hash.main)) st.main = hash.main;
+ if (hash.view) { st.mode = hash.view.mode; st.precOpacity = hash.view.opacity; }
+ if (hash.base) st.base = hash.base;
+ }
+ return st;
+}
+
+function saveState() {
+ try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
+}
+
+// --- hash de vue ----------------------------------------------------------
+// #z/lat/lng&M=principale&P=mode:opacité&B=on:opacité:sombre
+function parseHash() {
+ const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
+ if (!m) return null;
+ const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
+ main: null, view: null, base: null };
+ const seg = {};
+ for (const part of m[4].split('&')) {
+ const i = part.indexOf('=');
+ if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
+ }
+ if (seg.M) out.main = seg.M;
+ if (seg.P) {
+ const f = seg.P.split(':');
+ const mode = validMode(f[0]);
+ if (mode) out.view = { mode, opacity: clamp01(Number(f[1]) / 100, 0.6) };
+ }
+ if (seg.B) {
+ const f = seg.B.split(':');
+ if (f.length === 3) {
+ const o = Number(f[1]);
+ out.base = { on: f[0] === '1', opacity: Number.isFinite(o) ? o / 100 : 0.85, dark: f[2] === '1' };
+ }
+ }
+ return out;
+}
+
+function viewHash(full) {
+ const c = map.getCenter();
+ let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6);
+ if (full) {
+ if (STATE.main) h += '&M=' + STATE.main;
+ h += '&P=' + STATE.mode + ':' + Math.round(STATE.precOpacity * 100);
+ h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
+ + ':' + (STATE.base.dark ? '1' : '0');
+ }
+ return h;
+}
+
+let hashTimer = null;
+function saveHashSoon() {
+ clearTimeout(hashTimer);
+ hashTimer = setTimeout(() => {
+ history.replaceState(null, '', location.pathname + location.search + viewHash(false));
+ }, 350);
+}
+
+// --- couches --------------------------------------------------------------
+function tileUrl(key) {
+ return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
+}
+
+// Couche à niveaux standard pairs seulement (stockage réduit côté serveur) :
+// à un zoom impair, les tuiles du niveau pair SUPÉRIEUR sont affichées
+// réduites de moitié (nettes). Exception : le niveau natif lui-même (19,
+// impair — 0,2 m/px) est demandé tel quel, rendu à la volée par le serveur.
+// Le replier sur le niveau pair inférieur plafonnait l'affichage à 0,4 m/px,
+// étiré ensuite par le navigateur : la pleine résolution n'était jamais vue.
+// Au-delà du natif, Leaflet étire le niveau natif (jamais de tuile agrandie
+// côté serveur). Le niveau de tuile Leaflet est ici la numérotation standard
+// (zoomOffset −1 avec des tuiles de 512 px).
+const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
+ _clampZoom(zoom) {
+ let z = L.TileLayer.prototype._clampZoom.call(this, zoom);
+ const maxN = this.options.maxNativeZoom;
+ if (this.options.evenLevels && z % 2 && (maxN === undefined || z < maxN)) z += 1;
+ return z;
+ }
+});
+
+function buildLayers() {
+ // Conteneur isolé : `isolation: isolate` cantonne le « produit » de la
+ // précision à la couche principale. Sans lui, il assombrirait aussi le fond
+ // OSM. Les panneaux sont réutilisés si déjà créés (rafraîchissement après
+ // un run de génération).
+ const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
+ stack.style.zIndex = 400;
+ stack.style.isolation = 'isolate';
+ META.layers.forEach((info) => {
+ const paneName = 'lidar-' + info.key;
+ const pane = map.getPane(paneName) || map.createPane(paneName, stack);
+ const existing = LAYERS.get(info.key);
+ if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); }
+ const layer = new EvenLevelTileLayer(tileUrl(info.key), {
+ evenLevels: !!META.even_levels,
+ pane: paneName,
+ // Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
+ // pas de requêtes inutiles sur le reste du monde.
+ bounds: META.bounds
+ ? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]])
+ : undefined,
+ tileSize: META.tile_size,
+ zoomOffset: META.zoom_offset,
+ minNativeZoom: META.min_zoom,
+ maxNativeZoom: META.max_native_zoom,
+ minZoom: 0,
+ maxZoom: 19,
+ // Tuiles de 512 px : une rangée de réserve hors écran vaut les deux
+ // rangées de 256 px du fond. Deux rangées doublaient les images
+ // décodées gardées en mémoire (1 Mo chacune) pour peu de gain.
+ keepBuffer: 1,
+ updateWhenZooming: false,
+ crossOrigin: false,
+ errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ attribution: META.attribution
+ });
+ LAYERS.set(info.key, layer);
+ });
+ applyLayers();
+}
+
+// Mode réellement appliqué : sans couche précision servie, relief seul.
+function effectiveMode() {
+ return precisionKey() ? STATE.mode : 'relief';
+}
+
+function applyLayers() {
+ const prec = precisionKey();
+ const mode = effectiveMode();
+ for (const [key, layer] of LAYERS) {
+ const pane = map.getPane('lidar-' + key);
+ let show, opacity = 1, blend = 'normal';
+ if (key === prec) {
+ show = mode !== 'relief';
+ if (mode === 'both') { opacity = STATE.precOpacity; blend = 'multiply'; }
+ } else {
+ show = key === STATE.main && mode !== 'precision';
+ }
+ if (pane) {
+ pane.style.zIndex = key === prec ? '402' : '401';
+ pane.style.mixBlendMode = blend;
+ }
+ if (show) {
+ if (!map.hasLayer(layer)) layer.addTo(map);
+ layer.setOpacity(opacity);
+ } else if (map.hasLayer(layer)) {
+ map.removeLayer(layer);
+ }
+ }
+ if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
+ else if (map.hasLayer(base)) map.removeLayer(base);
+ base.setOpacity(STATE.base.opacity);
+ const container = base.getContainer && base.getContainer();
+ if (container) container.classList.toggle('base-dark', STATE.base.dark);
+ updateRose();
+}
+
+function setMode(mode, announce) {
+ if (!validMode(mode)) return;
+ STATE.mode = mode;
+ saveState(); renderPanel(); applyLayers();
+ if (announce) toast('Affichage : ' + VIEW_MODES.find(m => m[0] === mode)[1], 1200);
+}
+
+function renderPrecLegend() {
+ const bar = el('precBar');
+ if (bar.childElementCount) return;
+ for (let k = 0; k < PREC_LEVELS; k++) {
+ const g = Math.round(255 * k / (PREC_LEVELS - 1));
+ const lo = 0.25 * Math.pow(2, k / 2);
+ const hi = 0.25 * Math.pow(2, (k + 1) / 2);
+ const cell = document.createElement('i');
+ cell.style.background = 'rgb(' + g + ',' + g + ',' + g + ')';
+ cell.title = k === 0 ? '≤ ' + hi.toFixed(2).replace('.', ',') + ' pt/m²'
+ : k === PREC_LEVELS - 1 ? '≥ ' + Math.round(lo) + ' pts/m²'
+ : lo.toFixed(lo < 10 ? 1 : 0).replace('.', ',') + '–'
+ + hi.toFixed(hi < 10 ? 1 : 0).replace('.', ',') + ' pts/m²';
+ bar.appendChild(cell);
+ }
+}
+
+function renderPanel() {
+ const labels = new Map(META.layers.map(l => [l.key, l]));
+ const mains = mainKeys(META);
+ const info = labels.get(STATE.main);
+ const name = el('mainName'), sel = el('mainSel');
+ name.textContent = info ? info.label : 'Aucune couche';
+ name.title = info ? (info.description || info.label) : '';
+ // Plusieurs couches principales possibles : menu ; sinon simple libellé.
+ const many = mains.length > 1;
+ name.hidden = many;
+ sel.hidden = !many;
+ if (many) {
+ sel.innerHTML = mains.map(k => '' + labels.get(k).label + ' ').join('');
+ }
+ const prec = precisionKey();
+ el('precBlock').hidden = !prec;
+ if (!prec) return;
+ renderPrecLegend();
+ const mode = effectiveMode();
+ el('modeSeg').querySelectorAll('button').forEach(b =>
+ b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
+ el('precCtl').hidden = mode !== 'both';
+ el('precOpacity').value = Math.round(STATE.precOpacity * 100);
+ el('precPct').textContent = Math.round(STATE.precOpacity * 100) + '%';
+ el('precLegend').hidden = mode === 'relief';
+}
+
+function renderBaseRow() {
+ const b = STATE.base;
+ const toggle = el('baseToggle');
+ toggle.classList.toggle('on', b.on);
+ toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF;
+ el('baseOpacity').value = Math.round(b.opacity * 100);
+ el('basePct').textContent = Math.round(b.opacity * 100) + '%';
+ el('baseRow').classList.toggle('on', b.on);
+}
+
+// --- fiche dalle ----------------------------------------------------------
+let tileSel = null; // cadre de la dalle sélectionnée
+
+function esc(v) {
+ return String(v == null ? '' : v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+}
+
+function fmtDate(v, withTime) {
+ if (!v) return '—';
+ const d = new Date(v);
+ if (isNaN(d)) return esc(v);
+ const opt = withTime ? { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' }
+ : { dateStyle: 'long', timeZone: 'Europe/Paris' };
+ return d.toLocaleString('fr-FR', opt);
+}
+
+function kv(rows) {
+ return '' + rows.filter(r => r[1] != null && r[1] !== '')
+ .map(r => '' + r[0] + ' ' + r[1] + ' ').join('') + ' ';
+}
+
+function selectTileOutline(data) {
+ if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
+ 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,
+ fill: false, dashArray: '6 4',
+ interactive: false }).addTo(map);
+}
+
+function ignSection(ign) {
+ if (!ign) return 'Chargement des informations IGN…
';
+ if (ign.error) return 'Catalogue IGN injoignable pour le moment.
';
+ if (!ign.found) return 'Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).
';
+ const start = ign.acquisition_start, end = ign.acquisition_end;
+ let when = fmtDate(start, true);
+ if (start && end && String(start).slice(0, 10) === String(end).slice(0, 10) && String(end).length > 10) {
+ when = fmtDate(start, true) + ' → ' + new Date(end).toLocaleTimeString('fr-FR',
+ { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
+ } else if (end && String(end).slice(0, 10) !== String(start || '').slice(0, 10)) {
+ when = fmtDate(start) + ' → ' + fmtDate(end);
+ }
+ const pts = ign.points ? Number(ign.points).toLocaleString('fr-FR') + ' points' : null;
+ return kv([
+ ['Scan LiDAR', when],
+ ['Capteur', (ign.sensors || []).map(esc).join(' ')],
+ ['Mission', esc(ign.mission)],
+ ['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('pour ' + ign.acquisition_owner)]
+ .filter(Boolean).map(esc).join(' ')],
+ ['Édition IGN', ign.edition_date ? fmtDate(ign.edition_date) : null],
+ ['Classement', esc(ign.classification_process)],
+ ['Nuage', pts],
+ ]) + (ign.download_url
+ ? '⤓ Télécharger le nuage LiDAR IGN (.copc.laz) '
+ : '') + (ign.stac_url
+ ? '' : '');
+}
+
+function calageSection(c) {
+ if (!c || !c.beams || !c.beams.length) return '';
+ const offs = Object.entries(c.offsets_cm || {}).map(([k, v]) => k + ' : ' + (v > 0 ? '+' : '') + String(v).replace('.', ',') + ' cm');
+ const lines = Object.values(c.lines_rms_cm || {});
+ return '
Recalage vertical des passes ' + kv([
+ ['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
+ ['Décalages', offs.length ? offs.join(' ') : 'aucun ≥ 0,5 cm'],
+ ['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
+ ]) + '';
+}
+
+function renderTileCard(data, ign) {
+ const regenBtn = (GEN.available && GEN.allowed)
+ ? ''
+ : '';
+ el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
+ const bb = data.l93_bbox || [];
+ let html = '' + esc(data.name) + '
' +
+ '
Emprise ' + kv([
+ ['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') +
+ ' Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null],
+ ['Surface', '1 km × 1 km'],
+ ]) + '' +
+ '
Acquisition LiDAR (IGN) ' + ignSection(ign) + '';
+ if (data.found) {
+ const viz = (data.layers || []).map(k => {
+ const info = META.layers.find(l => l.key === k);
+ return info ? info.label : k;
+ }).join(', ');
+ html += '
Rendu ' + kv([
+ ['Résolution', String(data.resolution).replace('.', ',') + ' m/px'],
+ ['Classification', esc(data.method || 'inconnue')],
+ ['Généré le', esc(data.generated || '—')],
+ ['Couches', esc(viz || '—')],
+ ]) + '' + calageSection(data.calage);
+ } else {
+ html += 'Pas encore de rendu sur cette dalle.
';
+ }
+ el('tileBody').innerHTML = html + regenBtn;
+ const btn = el('tileRegen');
+ if (btn) btn.addEventListener('click', genAddCellFromQuery);
+ el('tilecard').hidden = false;
+}
+
+function showTileInfo(data, latlng) {
+ lastTileQuery = latlng || lastTileQuery;
+ if (!data) {
+ el('tileTitle').textContent = 'Dalle';
+ el('tileBody').innerHTML = 'Serveur injoignable : réessayez dans un instant.
';
+ el('tilecard').hidden = false;
+ return;
+ }
+ selectTileOutline(data);
+ renderTileCard(data, null);
+ // Fiche IGN à part : la sélection s'affiche sans attendre le catalogue.
+ const key = data.col + '_' + data.row;
+ showTileInfo.current = key;
+ fetch('api/map/ign?col=' + data.col + '&row=' + data.row)
+ .then(r => r.ok ? r.json() : { error: 'HTTP ' + r.status })
+ .catch(() => ({ error: 'réseau' }))
+ .then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
+}
+
+// --- rose des vents du relief orienté --------------------------------------
+// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
+// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
+// chroma 60 · L*(100−L*)/2500, à clarté fixe.
+function labToRgb(L, a, b) {
+ const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
+ const finv = t => (t > 6 / 29 ? t * t * t : 3 * (6 / 29) * (6 / 29) * (t - 4 / 29));
+ const X = 0.95047 * finv(fx), Y = finv(fy), Z = 1.08883 * finv(fz);
+ const lin = [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z,
+ 0.0557 * X - 0.2040 * Y + 1.0570 * Z];
+ return lin.map(c => {
+ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;
+ return Math.round(Math.min(1, Math.max(0, v)) * 255);
+ });
+}
+
+function drawRose() {
+ const cv = el('roseCanvas');
+ if (!cv || drawRose.done) return;
+ drawRose.done = true;
+ const dpr = window.devicePixelRatio || 1, size = 132;
+ cv.width = size * dpr; cv.height = size * dpr;
+ const ctx = cv.getContext('2d');
+ ctx.scale(dpr, dpr);
+ const c = size / 2, rOut = 50, rIn = 22, L = 64;
+ const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
+ const img = ctx.createImageData(size * dpr, size * dpr);
+ for (let py = 0; py < size * dpr; py++) {
+ for (let px = 0; px < size * dpr; px++) {
+ const x = px / dpr - c, y = py / dpr - c, r = Math.hypot(x, y);
+ if (r > rOut || r < rIn) continue;
+ const compass = (Math.atan2(x, -y) * 180 / Math.PI + 360) % 360; // 0 = N, sens horaire
+ const h = ((compass + 90) % 360) * Math.PI / 180;
+ const [R, G, B] = labToRgb(L, chroma * Math.cos(h), chroma * Math.sin(h));
+ const i = (py * size * dpr + px) * 4;
+ img.data[i] = R; img.data[i + 1] = G; img.data[i + 2] = B; img.data[i + 3] = 255;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.font = '600 11px system-ui, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
+ for (const [t, deg] of labels) {
+ const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
+ const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
+ ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
+ if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
+ ctx.fillText(t, x, y);
+ }
+}
+
+function updateRose() {
+ const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
+ && LAYERS.get('relief_oriente'));
+ el('rose').hidden = !on;
+ if (on) drawRose();
+}
+
+// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
+function renderUseCard() {
+ const origin = location.origin;
+ const opts = META.layers.map(l =>
+ '' + l.label + ' ').join('');
+ el('useBody').innerHTML =
+ '' + opts + ' ' +
+ 'Copier
' +
+ 'URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ 'Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
';
+ const sel = el('useLayer');
+ const refresh = () => {
+ const key = sel.value;
+ el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png';
+ const a = el('useJson');
+ a.textContent = origin + '/tiles/' + key + '.json';
+ a.href = origin + '/tiles/' + key + '.json';
+ };
+ sel.addEventListener('change', refresh);
+ el('useCopy').addEventListener('click', () => {
+ const text = el('useUrl').textContent;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => toast('URL copiée'),
+ () => window.prompt('URL XYZ :', text));
+ } else { window.prompt('URL XYZ :', text); }
+ });
+ refresh();
+ el('usecard').hidden = false;
+}
+
+// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
+// Portée de la webapp historique : dessin de zone (+), passe « tout
+// compléter » (⤒), sélection cumulée (clic fiche dalle), options de run,
+// file d'attente et progression dalle par dalle (cadres sur la carte).
+const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
+const genSel = new Set(); // clés "col,row" des dalles retenues
+const genCorners = new Map(); // clé → coins GPS [[lat, lon] × 4]
+const genPolys = new Map(); // clé → polygone de surlignage
+const genFrames = new Map(); // nom → cadre coloré de dalle en cours
+let genDrawing = false, genDrawStart = null, genDrawRect = null;
+let genIgnoreClickUntil = 0;
+let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
+
+for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670], ['tileSel', 680]]) {
+ map.createPane(pane).style.zIndex = String(z);
+}
+
+function genKey(c, r) { return c + ',' + r; }
+
+function genApi(method, url, body) {
+ return fetch(url, {
+ method: method,
+ headers: body ? { 'Content-Type': 'application/json' } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ }).then(r => {
+ if (!r.ok) {
+ return r.json().catch(() => ({})).then(d => {
+ throw new Error((d && d.detail) || ('HTTP ' + r.status));
+ });
+ }
+ return r.json();
+ });
+}
+
+function genOpenCard(msg) {
+ if (msg !== undefined) el('genMsg').textContent = msg;
+ el('gencard').hidden = false;
+}
+
+function genFillViz() {
+ el('genViz').innerHTML = META.layers.map(l =>
+ '' + l.label + ' ').join('');
+}
+
+function genSelectedViz() {
+ return Array.from(el('genViz').selectedOptions).map(o => o.value);
+}
+
+function genRefreshSel() {
+ for (const poly of genPolys.values()) poly.remove();
+ genPolys.clear();
+ for (const [key, corners] of genCorners.entries()) {
+ if (!genSel.has(key) || !corners || corners.length < 4) continue;
+ const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
+ pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
+ }).addTo(map);
+ poly.on('click', (e) => {
+ L.DomEvent.stopPropagation(e);
+ genRemoveCell(key);
+ toast('Dalle retirée de la sélection');
+ });
+ genPolys.set(key, poly);
+ }
+ el('genGo').disabled = !genSel.size;
+ if (genSel.size) {
+ el('genMsg').textContent = genSel.size + ' dalle(s) sélectionnée(s) — ' +
+ 'cliquez un surlignage pour retirer une dalle, réglez les options puis « Générer ».';
+ }
+}
+
+function genRemoveCell(key) {
+ genSel.delete(key);
+ genCorners.delete(key);
+ genRefreshSel();
+}
+
+function genAddCells(cells) {
+ let added = 0;
+ for (const c of (cells || [])) {
+ const k = genKey(c.col, c.row);
+ if (!genSel.has(k)) { genSel.add(k); added++; }
+ if (c.corners) genCorners.set(k, c.corners);
+ }
+ genRefreshSel();
+ return added;
+}
+
+function genAddCellFromQuery() {
+ if (!lastTileQuery) return;
+ genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
+ .then(d => {
+ genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
+ genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
+ el('tilecard').hidden = true;
+ })
+ .catch(msg => toast('Échec : ' + msg.message, 6000));
+}
+
+function genSetDrawing(on) {
+ genDrawing = on;
+ document.body.classList.toggle('drawing', on);
+ el('btnZone').classList.toggle('accent', on);
+ if (on) map.dragging.disable(); else map.dragging.enable();
+ if (!on && genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
+ genDrawStart = null;
+ if (on) genOpenCard('Dessinez un rectangle sur la carte (re-cliquez + pour annuler).');
+}
+
+map.on('mousedown', (e) => {
+ if (!genDrawing) return;
+ genDrawStart = e.latlng;
+ if (genDrawRect) genDrawRect.remove();
+ genDrawRect = L.rectangle([genDrawStart, genDrawStart], {
+ pane: 'genDraw', color: '#4cc9f0', weight: 2, dashArray: '6 4',
+ fillOpacity: 0.05, interactive: false,
+ }).addTo(map);
+});
+map.on('mousemove', (e) => {
+ if (!genDrawing || !genDrawStart || !genDrawRect) return;
+ genDrawRect.setBounds(L.latLngBounds(genDrawStart, e.latlng));
+});
+map.on('mouseup', (e) => {
+ if (!genDrawing || !genDrawStart) return;
+ const b = L.latLngBounds(genDrawStart, e.latlng);
+ genDrawStart = null;
+ genIgnoreClickUntil = Date.now() + 400; // pas de fiche dalle après un dessin
+ if (Math.abs(b.getWest() - b.getEast()) < 1e-9 ||
+ Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
+ genSetDrawing(false);
+ if (genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
+ genOpenCard('Analyse de la zone…');
+ genApi('POST', 'api/preview', {
+ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
+ regenerate: el('genRegen').checked,
+ viz: genSelectedViz(),
+ }).then(d => {
+ const added = genAddCells(d.cells);
+ genOpenCard((added ? added + ' nouvelle(s) dalle(s) retenue(s)'
+ : 'zone déjà couverte — cochez « Régénérer » pour forcer') +
+ (d.capped ? ' — zone plafonnée, resserrez le rectangle' : '') + '.');
+ }).catch(msg => genOpenCard('Erreur : ' + msg.message));
+});
+
+function genComplete() {
+ genOpenCard('Analyse des dalles déjà téléchargées…');
+ genApi('POST', 'api/preview', {
+ all_missing: true,
+ regenerate: el('genRegen').checked,
+ viz: genSelectedViz(),
+ }).then(d => {
+ const added = genAddCells(d.cells);
+ genOpenCard((added || d.count || 0) + ' dalle(s) à compléter' +
+ (d.capped ? ' — plafonnées à 10 000 par run' : '') + '.');
+ if (genPolys.size) {
+ const bounds = L.latLngBounds([]);
+ genPolys.forEach(p => bounds.extend(p.getBounds()));
+ map.fitBounds(bounds, { padding: [40, 40] });
+ }
+ }).catch(msg => genOpenCard('Erreur : ' + msg.message));
+}
+
+function genLaunch() {
+ const tiles = [...genSel].map(k => k.split(',').map(Number));
+ if (!tiles.length) return;
+ genApi('POST', 'api/generate', {
+ tiles: tiles,
+ regenerate: el('genRegen').checked,
+ viz: genSelectedViz(),
+ }).then(d => {
+ genSel.clear();
+ genCorners.clear();
+ genRefreshSel();
+ GEN.wasRunning = true;
+ genPollNow();
+ toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)')
+ : ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)'));
+ }).catch(msg => toast('Échec : ' + msg.message, 6000));
+}
+
+// Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en
+// attente, rouge = échec) — redraw complet à chaque sondage, c'est peu.
+function genDrawFrames(tiles) {
+ for (const poly of genFrames.values()) poly.remove();
+ genFrames.clear();
+ for (const t of (tiles || [])) {
+ if (t.state === 'done' || !t.corners || t.corners.length < 4) continue;
+ const cls = t.state === 'running' ? 'gen-frame-running'
+ : t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending';
+ const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), {
+ pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15,
+ interactive: false,
+ }).addTo(map);
+ genFrames.set(t.name || JSON.stringify(t.corners[0]), poly);
+ }
+}
+
+// Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de
+// tuile neuf) et la maintenance de pyramide est relancée côté serveur.
+function genAfterRun() {
+ fetch('api/map/meta').then(r => r.json()).then(meta => {
+ META = meta;
+ buildLayers();
+ renderPanel();
+ renderBaseRow();
+ applyLayers();
+ }).catch(() => {});
+ fetch('api/map/background', { method: 'POST' }).catch(() => {});
+}
+
+function genUpdateStatus(d) {
+ const queued = (d.queue || []).length;
+ if (d.running || queued) {
+ el('genActions').hidden = true;
+ el('genOpts').hidden = true;
+ el('genRun').hidden = false;
+ const tiles = d.tiles || [];
+ const done = tiles.filter(t => t.state === 'done').length;
+ const failed = tiles.filter(t => t.state === 'failed').length;
+ let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)')
+ : 'Démarrage du run suivant…';
+ if (failed) txt += ' • ' + failed + ' échec(s)';
+ if (queued) txt += ' • ' + queued + ' demande(s) en file';
+ el('genProgress').textContent = txt;
+ el('genLog').textContent = (d.log || []).slice(-12).join('\n');
+ genDrawFrames(tiles);
+ GEN.wasRunning = true;
+ } else {
+ el('genRun').hidden = true;
+ el('genActions').hidden = !GEN.available;
+ el('genOpts').hidden = false;
+ genDrawFrames(null);
+ if (GEN.wasRunning) {
+ GEN.wasRunning = false;
+ toast(d.returncode === 0 ? 'Génération terminée'
+ : 'Génération interrompue (code ' + d.returncode + ')', 6000);
+ genAfterRun();
+ }
+ }
+ el('genBadge').textContent = d.running ? '● en cours'
+ : queued ? ('⏱ ' + queued) : '';
+ el('btnZone').classList.toggle('busy', !!d.running || !!queued);
+}
+
+function genPollNow() {
+ if (!GEN.available) return;
+ fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
+ if (d) genUpdateStatus(d);
+ genPollSoon();
+ }).catch(() => genPollSoon());
+}
+
+function genPollSoon() {
+ clearTimeout(GEN.timer);
+ GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000);
+}
+
+el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
+el('btnComplete').addEventListener('click', genComplete);
+el('genClose').addEventListener('click', () => {
+ el('gencard').hidden = true;
+ if (genDrawing) genSetDrawing(false);
+});
+el('genClear').addEventListener('click', () => {
+ genSel.clear();
+ genCorners.clear();
+ genRefreshSel();
+ genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).');
+});
+el('genGo').addEventListener('click', genLaunch);
+el('genStop').addEventListener('click', () =>
+ genApi('POST', 'api/stop')
+ .then(() => toast('Arrêt demandé'))
+ .catch(msg => toast('Échec : ' + msg.message, 6000)));
+el('genQueueClear').addEventListener('click', () =>
+ genApi('POST', 'api/queue/clear')
+ .then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
+ .catch(msg => toast('Échec : ' + msg.message, 6000)));
+
+// --- export PDF (planche d'impression terrain) ---
+// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte
+// se déplace librement autour, on le fait glisser à la souris ou au doigt.
+// Géométrie exacte calculée par le serveur (projection Lambert 93).
+const PRINT_LS = 'lidar-print';
+const PRINT = { on: false, rect: null, handle: null, seq: 0, center: null, corners: null, drag: null };
+map.createPane('printFrame').style.zIndex = '690';
+
+function printSettings() {
+ return { paper: el('printPaper').value, orient: el('printOrient').value,
+ scale: el('printScale').value, title: el('printTitle').value.trim() };
+}
+
+function printQuery(s, withTitle) {
+ const c = PRINT.center || map.getCenter();
+ const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6),
+ paper: s.paper, orient: s.orient, scale: s.scale });
+ if (withTitle && s.title) q.set('title', s.title);
+ return q.toString();
+}
+
+function printSave() {
+ const s = printSettings();
+ if (PRINT.center) s.center = [PRINT.center.lat, PRINT.center.lng];
+ try { localStorage.setItem(PRINT_LS, JSON.stringify(s)); } catch (e) { /* privé */ }
+}
+
+function printLoad() {
+ try {
+ const s = JSON.parse(localStorage.getItem(PRINT_LS) || '{}');
+ for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'],
+ ['scale', 'printScale'], ['title', 'printTitle']]) {
+ if (s[k] != null) el(id).value = s[k];
+ }
+ if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center);
+ } catch (e) { /* privé ou illisible : réglages par défaut */ }
+}
+
+// Recadre sur le cadre en laissant libre la bande basse couverte par les
+// cartes (couches, export…) : sinon le bas du cadre passe dessous.
+function printFit() {
+ if (!PRINT.corners) return;
+ const box = map.getContainer().getBoundingClientRect();
+ let bottom = 0;
+ for (const c of document.querySelectorAll('.card')) {
+ if (c.hidden || !c.offsetParent) continue;
+ const r = c.getBoundingClientRect();
+ if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top);
+ }
+ map.fitBounds(L.latLngBounds(PRINT.corners),
+ { paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] });
+}
+
+function printDraw(corners) {
+ PRINT.corners = corners;
+ if (PRINT.rect) PRINT.rect.setLatLngs(corners);
+ else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: '#ffd166', weight: 2,
+ dashArray: '8 5', fill: false, interactive: false }).addTo(map);
+ if (PRINT.handle) { PRINT.handle.setLatLng(PRINT.center); return; }
+ PRINT.handle = L.marker(PRINT.center, {
+ pane: 'printFrame', draggable: true, autoPan: true, keyboard: false,
+ title: 'Faire glisser pour déplacer la zone imprimée',
+ icon: L.divIcon({ className: 'print-handle', html: '✥', iconSize: [34, 34] }),
+ }).addTo(map);
+ PRINT.handle.on('dragstart', printDragStart);
+ PRINT.handle.on('drag', printDragMove);
+ PRINT.handle.on('dragend', printDragEnd);
+}
+
+// fit : recadrer la vue sur le cadre une fois la géométrie reçue.
+function printRefreshFrame(fit) {
+ if (!PRINT.on) return;
+ const seq = ++PRINT.seq;
+ fetch('api/export/frame?' + printQuery(printSettings(), false))
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
+ .then(d => {
+ if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return;
+ printDraw(d.corners);
+ el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m';
+ if (fit === true) printFit();
+ })
+ .catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; });
+}
+
+// Glisser la poignée : le cadre suit en direct (translation en pixels de calque,
+// stables pendant le défilement automatique),
+// géométrie exacte redemandée au serveur au relâchement.
+function printDragStart() {
+ PRINT.drag = { start: map.latLngToLayerPoint(PRINT.handle.getLatLng()),
+ corners: PRINT.corners.map(c => map.latLngToLayerPoint(c)) };
+}
+
+function printDragMove() {
+ const d = PRINT.drag;
+ if (!d) return;
+ const delta = map.latLngToLayerPoint(PRINT.handle.getLatLng()).subtract(d.start);
+ PRINT.rect.setLatLngs(d.corners.map(p => map.layerPointToLatLng(p.add(delta))));
+}
+
+function printDragEnd() {
+ PRINT.drag = null;
+ PRINT.center = PRINT.handle.getLatLng();
+ PRINT.corners = PRINT.rect.getLatLngs()[0];
+ printSave();
+ printRefreshFrame();
+}
+
+function printCenterHere() {
+ PRINT.center = map.getCenter();
+ printSave();
+ printRefreshFrame();
+}
+
+function printSetOpen(on) {
+ PRINT.on = on;
+ el('printcard').hidden = !on;
+ el('btnPrint').classList.toggle('accent', on);
+ if (on) {
+ // 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();
+ printRefreshFrame(true);
+ } else {
+ if (PRINT.rect) PRINT.rect.remove();
+ if (PRINT.handle) PRINT.handle.remove();
+ PRINT.rect = PRINT.handle = PRINT.corners = null;
+ }
+}
+
+function printExport() {
+ const btn = el('printGo');
+ btn.disabled = true;
+ el('printMsg').textContent = 'Génération du PDF…';
+ fetch('api/export/pdf?' + printQuery(printSettings(), true))
+ .then(r => {
+ if (!r.ok) {
+ return r.json().catch(() => ({})).then(d => {
+ throw new Error((d && d.detail) || ('HTTP ' + r.status));
+ });
+ }
+ const m = /filename="([^"]+)"/.exec(r.headers.get('Content-Disposition') || '');
+ return r.blob().then(b => ({ b: b, name: m ? m[1] : 'relief.pdf' }));
+ })
+ .then(({ b, name }) => {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(b);
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
+ el('printMsg').textContent = 'Téléchargé : ' + name;
+ })
+ .catch(e => { el('printMsg').textContent = 'Échec : ' + e.message; })
+ .finally(() => { btn.disabled = false; });
+}
+
+el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
+el('printClose').addEventListener('click', () => printSetOpen(false));
+el('printGo').addEventListener('click', printExport);
+el('printHere').addEventListener('click', printCenterHere);
+el('printFit').addEventListener('click', printFit);
+for (const id of ['printPaper', 'printOrient', 'printScale']) {
+ el(id).addEventListener('change', () => { printSave(); printRefreshFrame(true); });
+}
+el('printTitle').addEventListener('change', printSave);
+printLoad();
+
+// --- démarrage ------------------------------------------------------------
+function fitData() {
+ if (META.bounds) {
+ map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]],
+ { padding: [30, 30] });
+ } else {
+ map.setView([46.6, 2.5], 6);
+ }
+}
+
+fetch('api/map/meta').then(r => r.json()).then(meta => {
+ META = meta;
+ if (!META.layers.length) {
+ toast('Aucune visualisation disponible dans le cache.', 10000);
+ }
+ STATE = loadState(META);
+ // L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
+ // suite : ce qui est à l'écran est ce qui sera rechargé.
+ saveState();
+ el('stat').textContent = META.layers.length + ' couche(s)';
+ const hash = parseHash();
+ if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
+ else fitData();
+ buildLayers();
+ renderPanel();
+ renderBaseRow();
+ genFillViz();
+ // Droits de génération : boutons affichés si le serveur peut générer
+ // (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
+ // réseau autorisé (LIDAR_REGEN_CIDR) — sinon masqués silencieusement.
+ fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
+ if (!d) return;
+ GEN.available = !!d.available;
+ GEN.allowed = d.regen_allowed !== false;
+ const show = GEN.available && GEN.allowed;
+ el('btnZone').hidden = !show;
+ el('btnComplete').hidden = !show;
+ if (show) { genUpdateStatus(d); genPollNow(); }
+ }).catch(() => {});
+}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
+
+let applyingHash = false;
+window.addEventListener('hashchange', () => {
+ // Lien partagé collé dans l'onglet courant : la vue (et l'état des couches
+ // s'il est présent) est appliquée sans rechargement.
+ if (applyingHash || !META) return;
+ const h = parseHash();
+ if (!h) return;
+ applyingHash = true;
+ map.setView([h.lat, h.lng], h.zoom);
+ if (h.main || h.view || h.base) {
+ STATE = loadState(META);
+ saveState(); renderPanel(); renderBaseRow(); applyLayers();
+ }
+ setTimeout(() => { applyingHash = false; }, 0);
+});
+
+map.on('moveend zoomend', saveHashSoon);
+map.on('click', (e) => {
+ if (genDrawing || Date.now() < genIgnoreClickUntil) return;
+ fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
+ .then(r => r.ok ? r.json() : null)
+ .catch(() => null)
+ .then(d => showTileInfo(d, e.latlng));
+});
+
+el('cardToggle').addEventListener('click', () =>
+ el('layercard').classList.toggle('collapsed'));
+el('tileClose').addEventListener('click', () => {
+ el('tilecard').hidden = true;
+ if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
+});
+el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
+el('btnUse').addEventListener('click', renderUseCard);
+el('btnShare').addEventListener('click', () => {
+ const url = location.origin + location.pathname + viewHash(true);
+ history.replaceState(null, '', url);
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url).then(() => toast('Lien copié'),
+ () => window.prompt('Lien :', url));
+ } else { window.prompt('Lien :', url); }
+});
+el('btnDefault').addEventListener('click', () => {
+ const body = {
+ main: STATE.main,
+ mode: STATE.mode,
+ precision_opacity: STATE.precOpacity,
+ base: STATE.base,
+ };
+ fetch('api/map/defaults', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
+ .then(() => {
+ META.default_main = STATE.main; META.default_mode = STATE.mode;
+ META.default_precision_opacity = STATE.precOpacity; META.default_base = STATE.base;
+ toast('Affichage enregistré comme défaut');
+ })
+ .catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
+});
+
+el('modeSeg').querySelectorAll('button').forEach(b =>
+ b.addEventListener('click', () => setMode(b.dataset.mode)));
+el('mainSel').addEventListener('change', (e) => {
+ STATE.main = e.target.value;
+ saveState(); renderPanel(); applyLayers();
+});
+el('precOpacity').addEventListener('input', (e) => {
+ STATE.precOpacity = Number(e.target.value) / 100;
+ el('precPct').textContent = e.target.value + '%';
+ const layer = LAYERS.get(precisionKey());
+ if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.precOpacity);
+});
+el('precOpacity').addEventListener('change', saveState);
+
+el('btnReset').addEventListener('click', () => {
+ // Oublie l'état local et reprend la configuration servie par le serveur.
+ try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
+ fetch('api/map/meta').then(r => r.json()).then(meta => {
+ META = meta;
+ STATE = defaultState(META);
+ saveState(); renderPanel(); renderBaseRow(); applyLayers();
+ toast('Configuration par défaut rétablie');
+ }).catch(() => toast('Serveur injoignable', 6000));
+});
+
+el('baseToggle').addEventListener('click', () => {
+ STATE.base.on = !STATE.base.on;
+ saveState(); renderBaseRow(); applyLayers();
+});
+el('baseName').addEventListener('click', () => {
+ STATE.base.dark = !STATE.base.dark;
+ saveState(); applyLayers();
+});
+el('baseOpacity').addEventListener('input', (e) => {
+ STATE.base.opacity = Number(e.target.value) / 100;
+ el('basePct').textContent = e.target.value + '%';
+ base.setOpacity(STATE.base.opacity);
+});
+el('baseOpacity').addEventListener('change', saveState);
+el('zoomIn').addEventListener('click', () => map.zoomIn(1));
+el('zoomOut').addEventListener('click', () => map.zoomOut(1));
+el('zoomFit').addEventListener('click', fitData);
+el('gps').addEventListener('click', () => {
+ if (!('geolocation' in navigator)) {
+ toast('Géolocalisation indisponible (HTTPS requis).', 6000);
+ return;
+ }
+ const btn = el('gps');
+ btn.classList.add('busy');
+ navigator.geolocation.getCurrentPosition((pos) => {
+ btn.classList.remove('busy');
+ const ll = [pos.coords.latitude, pos.coords.longitude];
+ map.setView(ll, Math.max(map.getZoom(), 16));
+ if (window._gpsMarker) window._gpsMarker.remove();
+ window._gpsMarker = L.circleMarker(ll, {
+ radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9
+ }).addTo(map);
+ }, (err) => {
+ btn.classList.remove('busy');
+ const why = err && err.code === 1 ? 'Autorisation refusée'
+ : err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible';
+ toast(why + ' (la géolocalisation exige HTTPS).', 6000);
+ }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
+});
+document.addEventListener('keydown', (e) => {
+ // P : relief → précision → les deux → relief (hors saisie de texte)
+ const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
+ if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
+ && META && STATE && precisionKey()) {
+ const i = VIEW_KEYS.indexOf(STATE.mode);
+ setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
+ return;
+ }
+ if (e.key === 'Escape') {
+ el('tilecard').hidden = true; el('usecard').hidden = true;
+ if (genDrawing) genSetDrawing(false);
+ }
+});
diff --git a/setup.py b/setup.py
index 147d722..f162c09 100644
--- a/setup.py
+++ b/setup.py
@@ -7,6 +7,7 @@ setup(
description='Pipeline LiDAR pour détection archéologique',
packages=find_packages(),
package_data={'lidar_pipeline': [
+ 'web/*',
'assets/vendor/*',
'assets/vendor/leaflet/*',
'assets/vendor/leaflet/images/*',